diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000000..b0382dcea4 --- /dev/null +++ b/lerna.json @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/lerna/schemas/lerna-schema.json", + "useWorkspaces": false, + "packages": ["packages/*"], + "version": "0.0.0", + "npmClient": "yarn" +} diff --git a/package.json b/package.json index 8a4d81867f..b54d40e96d 100644 --- a/package.json +++ b/package.json @@ -1,474 +1,19 @@ { - "name": "@k8slens/open-lens", - "productName": "OpenLens", - "description": "OpenLens - Open Source IDE for Kubernetes", - "homepage": "https://github.com/lensapp/lens", - "version": "6.4.0-alpha.0", - "repository": { - "type": "git", - "url": "git+https://github.com/lensapp/lens.git" - }, - "keywords": [], - "bugs": { - "url": "https://github.com/lensapp/lens/issues" - }, - "main": "static/build/main.js", - "exports": { - "./main": "./static/build/library/main.js", - "./renderer": "./static/build/library/renderer.js", - "./common": "./static/build/library/common.js", - "./styles": "./static/build/library/renderer.css" - }, - "typesVersions": { - "*": { - "main": [ - "./src/main/library.ts" - ], - "renderer": [ - "./src/renderer/library.ts" - ], - "common": [ - "./src/common/library.ts" - ] - } - }, - "files": [ - "build/download_binaries.ts", - "build/*.plist", - "build/installer.nsh", - "build/notarize.js", - "src/**/*", - "static/build/library/**/*", - "templates/**/*", - "types/*", - "tsconfig.json" - ], - "copyright": "© 2022 OpenLens Authors", - "license": "MIT", - "author": "OpenLens Authors ", + "name": "lens-monorepo", + "private": true, "scripts": { "adr:create": "echo \"What is the title?\"; read title; adr new \"$title\"", "adr:change-status": "echo \"Decision number?:\"; read decision; adr status $decision", "adr:update-readme": "adr update", "adr:list": "adr list", - "dev": "concurrently -i -k \"yarn run dev-run -C\" yarn:dev:*", - "dev-build": "concurrently yarn:compile:*", - "debug-build": "concurrently yarn:compile:main yarn:compile:extension-types", - "dev-run": "nodemon --watch ./static/build/main.js --exec \"electron --remote-debugging-port=9223 --inspect .\"", - "dev:main": "yarn run compile:main --watch --progress", - "dev:renderer": "yarn run ts-node webpack/dev-server.ts", - "compile-library": "env NODE_ENV=production yarn run webpack --config webpack/library-bundle.ts", - "compile": "env NODE_ENV=production concurrently yarn:compile:*", - "compile:main": "yarn run webpack --config webpack/main.ts", - "compile:renderer": "yarn run webpack --config webpack/renderer.ts", - "compile:extension-types": "yarn run webpack --config webpack/extensions.ts", - "compile:node-fetch": "yarn run webpack --config webpack/node-fetch.ts", - "prepare": "yarn run compile:node-fetch", - "npm:fix-extensions-package-version": "yarn run ts-node build/set_extensions_npm_version.ts", - "build:linux": "yarn run compile && electron-builder --linux --dir", - "build:mac": "yarn run compile && electron-builder --mac --dir", - "build:win": "yarn run compile && electron-builder --win --dir", - "integration": "jest --runInBand --detectOpenHandles --forceExit integration", - "test:unit": "func() { jest ${1} --watch --testPathIgnorePatterns integration; }; func", - "test:integration": "func() { jest ${1:-xyz} --watch --runInBand --detectOpenHandles --forceExit --modulePaths=[\"/integration/\"]; }; func", - "dist": "yarn run compile && electron-builder --publish onTag", - "dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null", - "download:binaries": "yarn run ts-node build/download_binaries.ts", - "build:tray-icons": "yarn run ts-node build/generate-tray-icons.ts", - "build:theme-vars": "yarn run ts-node build/build_theme_vars.ts", - "lint": "PROD=true yarn run eslint --ext js,ts,tsx --max-warnings=0 .", - "lint:fix": "yarn run lint --fix", - "mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", - "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", - "typedocs-extensions-api": "yarn run typedoc src/extensions/extension-api.ts", - "version-checkout": "cat package.json | jq '.version' -r | xargs printf \"release/v%s\" | xargs git checkout -b", - "version-commit": "cat package.json | jq '.version' -r | xargs printf \"release v%s\" | git commit --no-edit -s -F -", - "version": "yarn run version-checkout && git add package.json && yarn run version-commit", - "postversion": "git push --set-upstream ${GIT_REMOTE:-origin} release/v$npm_package_version", - "precreate-release-pr": "npx swc ./scripts/create-release-pr.ts -o ./scripts/create-release-pr.mjs", - "create-release-pr": "node ./scripts/create-release-pr.mjs" - }, - "config": { - "k8sProxyVersion": "0.3.0", - "bundledKubectlVersion": "1.23.3", - "bundledHelmVersion": "3.7.2", - "sentryDsn": "", - "contentSecurityPolicy": "script-src 'unsafe-eval' 'self'; frame-src https://*.lens.app:*/; img-src * data:", - "welcomeRoute": "/welcome" - }, - "engines": { - "node": ">=16 <17" - }, - "jest": { - "collectCoverage": false, - "verbose": true, - "transform": { - "^.+\\.(t|j)sx?$": [ - "@swc/jest" - ] - }, - "testEnvironment": "jsdom", - "resolver": "/src/jest-28-resolver.js", - "moduleNameMapper": { - "\\.(css|scss)$": "identity-obj-proxy", - "\\.(svg|png|jpg|eot|woff2?|ttf)$": "/__mocks__/assetMock.ts" - }, - "modulePathIgnorePatterns": [ - "/dist", - "/packages" - ], - "setupFiles": [ - "/src/jest.setup.ts", - "jest-canvas-mock" - ], - "globalSetup": "/src/jest.timezone.ts", - "setupFilesAfterEnv": [ - "/src/jest-after-env.setup.ts" - ], - "runtime": "@side/jest-runtime" - }, - "build": { - "generateUpdatesFilesForAllChannels": true, - "files": [ - "static/**/*" - ], - "afterSign": "build/notarize.js", - "extraResources": [ - { - "from": "templates/", - "to": "./templates/", - "filter": "**/*.yaml" - }, - "LICENSE" - ], - "linux": { - "executableName": "open-lens", - "category": "Network", - "artifactName": "${productName}-${version}.${arch}.${ext}", - "target": [ - "deb", - "rpm", - "AppImage" - ], - "extraResources": [ - { - "from": "binaries/client/linux/${arch}/kubectl", - "to": "./${arch}/kubectl" - }, - { - "from": "binaries/client/linux/${arch}/lens-k8s-proxy", - "to": "./${arch}/lens-k8s-proxy" - }, - { - "from": "binaries/client/linux/${arch}/helm", - "to": "./${arch}/helm" - } - ] - }, - "rpm": { - "fpm": [ - "--rpm-rpmbuild-define=%define _build_id_links none" - ] - }, - "mac": { - "executableName": "OpenLens", - "hardenedRuntime": true, - "gatekeeperAssess": false, - "entitlements": "build/entitlements.mac.plist", - "entitlementsInherit": "build/entitlements.mac.plist", - "extraResources": [ - { - "from": "binaries/client/darwin/${arch}/kubectl", - "to": "./${arch}/kubectl" - }, - { - "from": "binaries/client/darwin/${arch}/lens-k8s-proxy", - "to": "./${arch}/lens-k8s-proxy" - }, - { - "from": "binaries/client/darwin/${arch}/helm", - "to": "./${arch}/helm" - } - ] - }, - "win": { - "executableName": "OpenLens.exe", - "target": [ - "nsis" - ], - "extraResources": [ - { - "from": "binaries/client/windows/${arch}/kubectl.exe", - "to": "./${arch}/kubectl.exe" - }, - { - "from": "binaries/client/windows/${arch}/lens-k8s-proxy.exe", - "to": "./${arch}/lens-k8s-proxy.exe" - }, - { - "from": "binaries/client/windows/${arch}/helm.exe", - "to": "./${arch}/helm.exe" - } - ] - }, - "nsis": { - "include": "build/installer.nsh", - "oneClick": false, - "allowElevation": true, - "createStartMenuShortcut": true, - "allowToChangeInstallationDirectory": true - }, - "protocols": { - "name": "Lens Protocol Handler", - "schemes": [ - "lens" - ], - "role": "Viewer" - } - }, - "resolutions": { - "@astronautlabs/jsonpath/underscore": "^1.12.1" - }, - "dependencies": { - "@astronautlabs/jsonpath": "^1.1.0", - "@hapi/call": "^9.0.0", - "@hapi/subtext": "^7.0.4", - "@kubernetes/client-node": "^0.18.0", - "@material-ui/styles": "^4.11.5", - "@ogre-tools/fp": "^12.0.1", - "@ogre-tools/injectable": "^12.0.1", - "@ogre-tools/injectable-extension-for-auto-registration": "^12.0.1", - "@ogre-tools/injectable-extension-for-mobx": "^12.0.1", - "@ogre-tools/injectable-react": "^12.0.1", - "@sentry/electron": "^3.0.8", - "@sentry/integrations": "^6.19.3", - "@side/jest-runtime": "^1.0.1", - "abort-controller": "^3.0.0", - "auto-bind": "^4.0.0", - "await-lock": "^2.2.2", - "byline": "^5.0.0", - "chokidar": "^3.5.3", - "conf": "^7.1.2", - "crypto-js": "^4.1.1", - "electron-devtools-installer": "^3.2.0", - "electron-updater": "^4.6.5", - "electron-window-state": "^5.0.3", - "filehound": "^1.17.6", - "fs-extra": "^9.0.1", - "glob-to-regexp": "^0.4.1", - "got": "^11.8.6", - "grapheme-splitter": "^1.0.4", - "handlebars": "^4.7.7", - "history": "^4.10.1", - "hpagent": "^1.2.0", - "http-proxy": "^1.18.1", - "immer": "^9.0.17", - "joi": "^17.7.0", - "js-yaml": "^4.1.0", - "jsdom": "^16.7.0", - "lodash": "^4.17.15", - "marked": "^4.2.5", - "md5-file": "^5.0.0", - "mobx": "^6.7.0", - "mobx-observable-history": "^2.0.3", - "mobx-react": "^7.6.0", - "mobx-utils": "^6.0.4", - "moment": "^2.29.4", - "moment-timezone": "^0.5.40", - "node-fetch": "^3.3.0", - "node-pty": "0.10.1", - "npm": "^8.19.3", - "p-limit": "^3.1.0", - "path-to-regexp": "^6.2.0", - "proper-lockfile": "^4.1.2", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-material-ui-carousel": "^2.3.11", - "react-router": "^5.3.4", - "react-virtualized-auto-sizer": "^1.0.7", - "readable-stream": "^3.6.0", - "request": "^2.88.2", - "request-promise-native": "^1.0.9", - "rfc6902": "^5.0.1", - "selfsigned": "^2.1.1", - "semver": "^7.3.8", - "tar": "^6.1.13", - "tcp-port-used": "^1.0.2", - "tempy": "1.0.1", - "typed-regex": "^0.0.8", - "url-parse": "^1.5.10", - "uuid": "^8.3.2", - "win-ca": "^3.5.0", - "winston": "^3.8.2", - "winston-transport-browserconsole": "^1.0.5", - "ws": "^8.12.0", - "xterm-link-provider": "^1.3.1" + "build": "lerna run build", + "clean:node_modules": "lerna clean -y && rm -rf node_modules", + "mkdocs:serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", + "mkdocs:verify": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", + "test": "lerna run test:unit" }, "devDependencies": { - "@async-fn/jest": "1.6.4", - "@material-ui/core": "^4.12.3", - "@material-ui/icons": "^4.11.2", - "@material-ui/lab": "^4.0.0-alpha.60", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", - "@sentry/types": "^6.19.7", - "@swc/cli": "^0.1.59", - "@swc/core": "^1.3.25", - "@swc/jest": "^0.2.24", - "@testing-library/dom": "^7.31.2", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^12.1.5", - "@testing-library/user-event": "^13.5.0", - "@types/byline": "^4.2.33", - "@types/chart.js": "^2.9.36", - "@types/circular-dependency-plugin": "5.0.5", - "@types/cli-progress": "^3.11.0", - "@types/color": "^3.0.3", - "@types/command-line-args": "^5.2.0", - "@types/crypto-js": "^3.1.47", - "@types/dompurify": "^2.4.0", - "@types/electron-devtools-installer": "^2.2.1", - "@types/fs-extra": "^9.0.13", - "@types/glob-to-regexp": "^0.4.1", - "@types/gunzip-maybe": "^1.4.0", - "@types/hapi__call": "^9.0.0", - "@types/hapi__subtext": "^7.0.0", - "@types/html-webpack-plugin": "^3.2.6", - "@types/http-proxy": "^1.17.9", - "@types/jest": "^28.1.6", - "@types/js-yaml": "^4.0.5", - "@types/jsdom": "^16.2.14", - "@types/lodash": "^4.14.191", - "@types/marked": "^4.0.8", - "@types/md5-file": "^4.0.2", - "@types/memorystream": "^0.3.0", - "@types/mini-css-extract-plugin": "^2.4.0", - "@types/mock-fs": "^4.13.1", - "@types/node": "^16.18.11", - "@types/proper-lockfile": "^4.1.2", - "@types/randomcolor": "^0.5.7", - "@types/react": "^17.0.45", - "@types/react-beautiful-dnd": "^13.1.3", - "@types/react-dom": "^17.0.16", - "@types/react-router": "^5.1.19", - "@types/react-router-dom": "^5.3.3", - "@types/react-table": "^7.7.14", - "@types/react-virtualized-auto-sizer": "^1.0.1", - "@types/react-window": "^1.8.5", - "@types/readable-stream": "^2.3.13", - "@types/request": "^2.48.7", - "@types/request-promise-native": "^1.0.18", - "@types/semver": "^7.3.13", - "@types/sharp": "^0.31.1", - "@types/tar": "^6.1.3", - "@types/tar-stream": "^2.2.2", - "@types/tcp-port-used": "^1.0.1", - "@types/tempy": "^0.3.0", - "@types/triple-beam": "^1.3.2", - "@types/url-parse": "^1.4.8", - "@types/uuid": "^8.3.4", - "@types/webpack": "^5.28.0", - "@types/webpack-dev-server": "^4.7.2", - "@types/webpack-env": "^1.18.0", - "@types/webpack-node-externals": "^2.5.3", - "@typescript-eslint/eslint-plugin": "^5.48.1", - "@typescript-eslint/parser": "^5.48.1", "adr": "^1.4.3", - "ansi_up": "^5.1.0", - "chalk": "^4.1.2", - "chart.js": "^2.9.4", - "circular-dependency-plugin": "^5.2.2", - "cli-progress": "^3.11.2", - "color": "^3.2.1", - "command-line-args": "^5.2.1", - "concurrently": "^7.6.0", - "css-loader": "^6.7.3", - "deepdash": "^5.3.9", - "dompurify": "^2.4.3", - "electron": "^19.1.9", - "electron-builder": "^23.6.0", - "electron-notarize": "^0.3.0", - "esbuild": "^0.16.14", - "esbuild-loader": "^2.20.0", - "eslint": "^8.31.0", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-react": "7.31.11", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-unused-imports": "^2.0.0", - "fork-ts-checker-webpack-plugin": "^6.5.2", - "gunzip-maybe": "^1.4.2", - "html-webpack-plugin": "^5.5.0", - "identity-obj-proxy": "^3.0.0", - "ignore-loader": "^0.1.2", - "include-media": "^1.4.9", - "jest": "^28.1.3", - "jest-canvas-mock": "^2.3.1", - "jest-environment-jsdom": "^28.1.3", - "jest-mock-extended": "^2.0.9", - "make-plural": "^6.2.2", - "memfs": "^3.4.12", - "memorystream": "^0.3.1", - "mini-css-extract-plugin": "^2.7.2", - "mock-http": "^1.1.0", - "monaco-editor": "^0.29.1", - "monaco-editor-webpack-plugin": "^5.0.0", - "node-gyp": "^8.3.0", - "node-loader": "^2.0.0", - "nodemon": "^2.0.20", - "playwright": "^1.29.2", - "postcss": "^8.4.21", - "postcss-loader": "^6.2.1", - "query-string": "^7.1.3", - "randomcolor": "^0.6.2", - "react-beautiful-dnd": "^13.1.1", - "react-refresh": "^0.14.0", - "react-refresh-typescript": "^2.0.7", - "react-router-dom": "^5.3.4", - "react-select": "^5.7.0", - "react-select-event": "^5.5.1", - "react-table": "^7.8.0", - "react-window": "^1.8.8", - "sass": "^1.57.1", - "sass-loader": "^12.6.0", - "sharp": "^0.31.3", - "style-loader": "^3.3.1", - "tailwindcss": "^3.2.4", - "tar-stream": "^2.2.0", - "ts-loader": "^9.4.2", - "ts-node": "^10.9.1", - "type-fest": "^2.14.0", - "typed-emitter": "^1.4.0", - "typedoc": "0.23.24", - "typedoc-plugin-markdown": "^3.13.6", - "typescript": "^4.9.4", - "typescript-plugin-css-modules": "^3.4.0", - "webpack": "^5.75.0", - "webpack-cli": "^4.9.2", - "webpack-dev-server": "^4.11.1", - "webpack-node-externals": "^3.0.0", - "xterm": "^4.19.0", - "xterm-addon-fit": "^0.5.0" - }, - "peerDependencies": { - "@types/byline": "^4.2.33", - "@types/chart.js": "^2.9.36", - "@types/color": "^3.0.3", - "@types/crypto-js": "^3.1.47", - "@types/lodash": "^4.14.191", - "@types/proper-lockfile": "^4.1.2", - "@types/react-dom": "^17.0.16", - "@types/react-router-dom": "^5.3.3", - "@types/react-virtualized-auto-sizer": "^1.0.1", - "@types/react-window": "^1.8.5", - "@types/request-promise-native": "^1.0.18", - "@types/tar": "^6.1.3", - "@types/tcp-port-used": "^1.0.1", - "@types/url-parse": "^1.4.8", - "@types/uuid": "^8.3.4", - "monaco-editor": "^0.29.1", - "react-select": "^5.7.0", - "typed-emitter": "^1.4.0", - "xterm-addon-fit": "^0.5.0" + "lerna": "^6.3.0" } } diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 0000000000..81f59a8598 --- /dev/null +++ b/packages/core/.gitignore @@ -0,0 +1,5 @@ +static/build/ +build/webpack/ +binaries/ +dist/ +node_modules/ \ No newline at end of file diff --git a/Makefile b/packages/core/Makefile similarity index 100% rename from Makefile rename to packages/core/Makefile diff --git a/__mocks__/@sentry/electron/main.ts b/packages/core/__mocks__/@sentry/electron/main.ts similarity index 100% rename from __mocks__/@sentry/electron/main.ts rename to packages/core/__mocks__/@sentry/electron/main.ts diff --git a/__mocks__/@sentry/electron/renderer.ts b/packages/core/__mocks__/@sentry/electron/renderer.ts similarity index 100% rename from __mocks__/@sentry/electron/renderer.ts rename to packages/core/__mocks__/@sentry/electron/renderer.ts diff --git a/__mocks__/assetMock.ts b/packages/core/__mocks__/assetMock.ts similarity index 100% rename from __mocks__/assetMock.ts rename to packages/core/__mocks__/assetMock.ts diff --git a/__mocks__/electron-updater.ts b/packages/core/__mocks__/electron-updater.ts similarity index 100% rename from __mocks__/electron-updater.ts rename to packages/core/__mocks__/electron-updater.ts diff --git a/__mocks__/electron.ts b/packages/core/__mocks__/electron.ts similarity index 100% rename from __mocks__/electron.ts rename to packages/core/__mocks__/electron.ts diff --git a/__mocks__/node-pty.ts b/packages/core/__mocks__/node-pty.ts similarity index 100% rename from __mocks__/node-pty.ts rename to packages/core/__mocks__/node-pty.ts diff --git a/__mocks__/react-beautiful-dnd.tsx b/packages/core/__mocks__/react-beautiful-dnd.tsx similarity index 100% rename from __mocks__/react-beautiful-dnd.tsx rename to packages/core/__mocks__/react-beautiful-dnd.tsx diff --git a/__mocks__/react-virtualized-auto-sizer.tsx b/packages/core/__mocks__/react-virtualized-auto-sizer.tsx similarity index 100% rename from __mocks__/react-virtualized-auto-sizer.tsx rename to packages/core/__mocks__/react-virtualized-auto-sizer.tsx diff --git a/build/download_binaries.ts b/packages/core/build/download_binaries.ts similarity index 100% rename from build/download_binaries.ts rename to packages/core/build/download_binaries.ts diff --git a/build/entitlements.mac.plist b/packages/core/build/entitlements.mac.plist similarity index 100% rename from build/entitlements.mac.plist rename to packages/core/build/entitlements.mac.plist diff --git a/build/generate-tray-icons.ts b/packages/core/build/generate-tray-icons.ts similarity index 100% rename from build/generate-tray-icons.ts rename to packages/core/build/generate-tray-icons.ts diff --git a/build/icon.ico b/packages/core/build/icon.ico similarity index 100% rename from build/icon.ico rename to packages/core/build/icon.ico diff --git a/build/icon.png b/packages/core/build/icon.png similarity index 100% rename from build/icon.png rename to packages/core/build/icon.png diff --git a/build/icons/512x512.png b/packages/core/build/icons/512x512.png similarity index 100% rename from build/icons/512x512.png rename to packages/core/build/icons/512x512.png diff --git a/build/icons/512x512@2x.png b/packages/core/build/icons/512x512@2x.png similarity index 100% rename from build/icons/512x512@2x.png rename to packages/core/build/icons/512x512@2x.png diff --git a/build/installer.nsh b/packages/core/build/installer.nsh similarity index 100% rename from build/installer.nsh rename to packages/core/build/installer.nsh diff --git a/build/notarize.js b/packages/core/build/notarize.js similarity index 100% rename from build/notarize.js rename to packages/core/build/notarize.js diff --git a/build/set_extensions_npm_version.ts b/packages/core/build/set_extensions_npm_version.ts similarity index 100% rename from build/set_extensions_npm_version.ts rename to packages/core/build/set_extensions_npm_version.ts diff --git a/build/tray/trayIcon.png b/packages/core/build/tray/trayIcon.png similarity index 100% rename from build/tray/trayIcon.png rename to packages/core/build/tray/trayIcon.png diff --git a/build/tray/trayIcon@2x.png b/packages/core/build/tray/trayIcon@2x.png similarity index 100% rename from build/tray/trayIcon@2x.png rename to packages/core/build/tray/trayIcon@2x.png diff --git a/build/tray/trayIcon@3x.png b/packages/core/build/tray/trayIcon@3x.png similarity index 100% rename from build/tray/trayIcon@3x.png rename to packages/core/build/tray/trayIcon@3x.png diff --git a/build/tray/trayIcon@4x.png b/packages/core/build/tray/trayIcon@4x.png similarity index 100% rename from build/tray/trayIcon@4x.png rename to packages/core/build/tray/trayIcon@4x.png diff --git a/build/tray/trayIconCheckingForUpdates.png b/packages/core/build/tray/trayIconCheckingForUpdates.png similarity index 100% rename from build/tray/trayIconCheckingForUpdates.png rename to packages/core/build/tray/trayIconCheckingForUpdates.png diff --git a/build/tray/trayIconCheckingForUpdates@2x.png b/packages/core/build/tray/trayIconCheckingForUpdates@2x.png similarity index 100% rename from build/tray/trayIconCheckingForUpdates@2x.png rename to packages/core/build/tray/trayIconCheckingForUpdates@2x.png diff --git a/build/tray/trayIconCheckingForUpdates@3x.png b/packages/core/build/tray/trayIconCheckingForUpdates@3x.png similarity index 100% rename from build/tray/trayIconCheckingForUpdates@3x.png rename to packages/core/build/tray/trayIconCheckingForUpdates@3x.png diff --git a/build/tray/trayIconCheckingForUpdates@4x.png b/packages/core/build/tray/trayIconCheckingForUpdates@4x.png similarity index 100% rename from build/tray/trayIconCheckingForUpdates@4x.png rename to packages/core/build/tray/trayIconCheckingForUpdates@4x.png diff --git a/build/tray/trayIconCheckingForUpdatesTemplate.png b/packages/core/build/tray/trayIconCheckingForUpdatesTemplate.png similarity index 100% rename from build/tray/trayIconCheckingForUpdatesTemplate.png rename to packages/core/build/tray/trayIconCheckingForUpdatesTemplate.png diff --git a/build/tray/trayIconCheckingForUpdatesTemplate@2x.png b/packages/core/build/tray/trayIconCheckingForUpdatesTemplate@2x.png similarity index 100% rename from build/tray/trayIconCheckingForUpdatesTemplate@2x.png rename to packages/core/build/tray/trayIconCheckingForUpdatesTemplate@2x.png diff --git a/build/tray/trayIconCheckingForUpdatesTemplate@3x.png b/packages/core/build/tray/trayIconCheckingForUpdatesTemplate@3x.png similarity index 100% rename from build/tray/trayIconCheckingForUpdatesTemplate@3x.png rename to packages/core/build/tray/trayIconCheckingForUpdatesTemplate@3x.png diff --git a/build/tray/trayIconCheckingForUpdatesTemplate@4x.png b/packages/core/build/tray/trayIconCheckingForUpdatesTemplate@4x.png similarity index 100% rename from build/tray/trayIconCheckingForUpdatesTemplate@4x.png rename to packages/core/build/tray/trayIconCheckingForUpdatesTemplate@4x.png diff --git a/build/tray/trayIconTemplate.png b/packages/core/build/tray/trayIconTemplate.png similarity index 100% rename from build/tray/trayIconTemplate.png rename to packages/core/build/tray/trayIconTemplate.png diff --git a/build/tray/trayIconTemplate@2x.png b/packages/core/build/tray/trayIconTemplate@2x.png similarity index 100% rename from build/tray/trayIconTemplate@2x.png rename to packages/core/build/tray/trayIconTemplate@2x.png diff --git a/build/tray/trayIconTemplate@3x.png b/packages/core/build/tray/trayIconTemplate@3x.png similarity index 100% rename from build/tray/trayIconTemplate@3x.png rename to packages/core/build/tray/trayIconTemplate@3x.png diff --git a/build/tray/trayIconTemplate@4x.png b/packages/core/build/tray/trayIconTemplate@4x.png similarity index 100% rename from build/tray/trayIconTemplate@4x.png rename to packages/core/build/tray/trayIconTemplate@4x.png diff --git a/build/tray/trayIconUpdateAvailable.png b/packages/core/build/tray/trayIconUpdateAvailable.png similarity index 100% rename from build/tray/trayIconUpdateAvailable.png rename to packages/core/build/tray/trayIconUpdateAvailable.png diff --git a/build/tray/trayIconUpdateAvailable@2x.png b/packages/core/build/tray/trayIconUpdateAvailable@2x.png similarity index 100% rename from build/tray/trayIconUpdateAvailable@2x.png rename to packages/core/build/tray/trayIconUpdateAvailable@2x.png diff --git a/build/tray/trayIconUpdateAvailable@3x.png b/packages/core/build/tray/trayIconUpdateAvailable@3x.png similarity index 100% rename from build/tray/trayIconUpdateAvailable@3x.png rename to packages/core/build/tray/trayIconUpdateAvailable@3x.png diff --git a/build/tray/trayIconUpdateAvailable@4x.png b/packages/core/build/tray/trayIconUpdateAvailable@4x.png similarity index 100% rename from build/tray/trayIconUpdateAvailable@4x.png rename to packages/core/build/tray/trayIconUpdateAvailable@4x.png diff --git a/build/tray/trayIconUpdateAvailableTemplate.png b/packages/core/build/tray/trayIconUpdateAvailableTemplate.png similarity index 100% rename from build/tray/trayIconUpdateAvailableTemplate.png rename to packages/core/build/tray/trayIconUpdateAvailableTemplate.png diff --git a/build/tray/trayIconUpdateAvailableTemplate@2x.png b/packages/core/build/tray/trayIconUpdateAvailableTemplate@2x.png similarity index 100% rename from build/tray/trayIconUpdateAvailableTemplate@2x.png rename to packages/core/build/tray/trayIconUpdateAvailableTemplate@2x.png diff --git a/build/tray/trayIconUpdateAvailableTemplate@3x.png b/packages/core/build/tray/trayIconUpdateAvailableTemplate@3x.png similarity index 100% rename from build/tray/trayIconUpdateAvailableTemplate@3x.png rename to packages/core/build/tray/trayIconUpdateAvailableTemplate@3x.png diff --git a/build/tray/trayIconUpdateAvailableTemplate@4x.png b/packages/core/build/tray/trayIconUpdateAvailableTemplate@4x.png similarity index 100% rename from build/tray/trayIconUpdateAvailableTemplate@4x.png rename to packages/core/build/tray/trayIconUpdateAvailableTemplate@4x.png diff --git a/build/tsconfig.json b/packages/core/build/tsconfig.json similarity index 100% rename from build/tsconfig.json rename to packages/core/build/tsconfig.json diff --git a/integration/__tests__/app-preferences.tests.ts b/packages/core/integration/__tests__/app-preferences.tests.ts similarity index 100% rename from integration/__tests__/app-preferences.tests.ts rename to packages/core/integration/__tests__/app-preferences.tests.ts diff --git a/integration/__tests__/cluster-pages.tests.ts b/packages/core/integration/__tests__/cluster-pages.tests.ts similarity index 100% rename from integration/__tests__/cluster-pages.tests.ts rename to packages/core/integration/__tests__/cluster-pages.tests.ts diff --git a/integration/__tests__/command-palette.tests.ts b/packages/core/integration/__tests__/command-palette.tests.ts similarity index 100% rename from integration/__tests__/command-palette.tests.ts rename to packages/core/integration/__tests__/command-palette.tests.ts diff --git a/integration/helpers/minikube.ts b/packages/core/integration/helpers/minikube.ts similarity index 100% rename from integration/helpers/minikube.ts rename to packages/core/integration/helpers/minikube.ts diff --git a/integration/helpers/utils.ts b/packages/core/integration/helpers/utils.ts similarity index 100% rename from integration/helpers/utils.ts rename to packages/core/integration/helpers/utils.ts diff --git a/integration/tsconfig.json b/packages/core/integration/tsconfig.json similarity index 100% rename from integration/tsconfig.json rename to packages/core/integration/tsconfig.json diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..de0a7363b8 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,357 @@ +{ + "name": "@k8slens/open-lens", + "productName": "OpenLens", + "description": "OpenLens - Open Source IDE for Kubernetes", + "homepage": "https://github.com/lensapp/lens", + "version": "6.4.0-alpha.0", + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "keywords": [], + "bugs": { + "url": "https://github.com/lensapp/lens/issues" + }, + "main": "static/build/main.js", + "exports": { + "./main": "./static/build/library/main.js", + "./renderer": "./static/build/library/renderer.js", + "./common": "./static/build/library/common.js", + "./styles": "./static/build/library/renderer.css" + }, + "typesVersions": { + "*": { + "main": [ + "./static/build/library/src/main/library.d.ts" + ], + "renderer": [ + "./static/build/library/src/renderer/library.d.ts" + ], + "common": [ + "./static/build/library/src/common/library.d.ts" + ] + } + }, + "files": [ + "build/download_binaries.ts", + "build/*.plist", + "build/installer.nsh", + "build/notarize.js", + "static/build/library/**/*", + "templates/**/*", + "types/*", + "tsconfig.json" + ], + "copyright": "© 2022 OpenLens Authors", + "license": "MIT", + "author": "OpenLens Authors ", + "scripts": { + "build": "env NODE_ENV=production yarn run webpack --config webpack/library-bundle.ts", + "compile:node-fetch": "yarn run webpack --config webpack/node-fetch.ts", + "prepare": "yarn run compile:node-fetch", + "build:linux": "yarn run compile && electron-builder --linux --dir", + "build:mac": "yarn run compile && electron-builder --mac --dir", + "build:win": "yarn run compile && electron-builder --win --dir", + "integration": "jest --runInBand --detectOpenHandles --forceExit integration", + "test:unit": "func() { jest ${1} --watch --testPathIgnorePatterns integration; }; func", + "test:integration": "func() { jest ${1:-xyz} --watch --runInBand --detectOpenHandles --forceExit --modulePaths=[\"/integration/\"]; }; func", + "dist": "yarn run compile && electron-builder --publish onTag", + "dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null", + "download:binaries": "yarn run ts-node build/download_binaries.ts", + "build:tray-icons": "yarn run ts-node build/generate-tray-icons.ts", + "build:theme-vars": "yarn run ts-node build/build_theme_vars.ts", + "lint": "PROD=true yarn run eslint --ext js,ts,tsx --max-warnings=0 .", + "lint:fix": "yarn run lint --fix", + "version-checkout": "cat package.json | jq '.version' -r | xargs printf \"release/v%s\" | xargs git checkout -b", + "version-commit": "cat package.json | jq '.version' -r | xargs printf \"release v%s\" | git commit --no-edit -s -F -", + "version": "yarn run version-checkout && git add package.json && yarn run version-commit", + "postversion": "git push --set-upstream ${GIT_REMOTE:-origin} release/v$npm_package_version", + "precreate-release-pr": "npx swc ./scripts/create-release-pr.ts -o ./scripts/create-release-pr.mjs", + "create-release-pr": "node ./scripts/create-release-pr.mjs" + }, + "config": { + "k8sProxyVersion": "0.3.0", + "bundledKubectlVersion": "1.23.3", + "bundledHelmVersion": "3.7.2", + "sentryDsn": "", + "contentSecurityPolicy": "script-src 'unsafe-eval' 'self'; frame-src https://*.lens.app:*/; img-src * data:", + "welcomeRoute": "/welcome" + }, + "engines": { + "node": ">=16 <17" + }, + "jest": { + "collectCoverage": false, + "verbose": true, + "transform": { + "^.+\\.(t|j)sx?$": [ + "@swc/jest" + ] + }, + "testEnvironment": "jsdom", + "resolver": "/src/jest-28-resolver.js", + "moduleNameMapper": { + "\\.(css|scss)$": "identity-obj-proxy", + "\\.(svg|png|jpg|eot|woff2?|ttf)$": "/__mocks__/assetMock.ts" + }, + "modulePathIgnorePatterns": [ + "/dist", + "/packages", + "/static/build" + ], + "setupFiles": [ + "/src/jest.setup.ts", + "jest-canvas-mock" + ], + "globalSetup": "/src/jest.timezone.ts", + "setupFilesAfterEnv": [ + "/src/jest-after-env.setup.ts" + ], + "runtime": "@side/jest-runtime" + }, + "build": {}, + "resolutions": { + "@astronautlabs/jsonpath/underscore": "^1.12.1" + }, + "dependencies": { + "@astronautlabs/jsonpath": "^1.1.0", + "@hapi/call": "^9.0.0", + "@hapi/subtext": "^7.0.4", + "@kubernetes/client-node": "^0.18.0", + "@material-ui/styles": "^4.11.5", + "@ogre-tools/fp": "^12.0.1", + "@ogre-tools/injectable": "^12.0.1", + "@ogre-tools/injectable-extension-for-auto-registration": "^12.0.1", + "@ogre-tools/injectable-extension-for-mobx": "^12.0.1", + "@ogre-tools/injectable-react": "^12.0.1", + "@sentry/electron": "^3.0.8", + "@sentry/integrations": "^6.19.3", + "@side/jest-runtime": "^1.0.1", + "abort-controller": "^3.0.0", + "auto-bind": "^4.0.0", + "await-lock": "^2.2.2", + "byline": "^5.0.0", + "chokidar": "^3.5.3", + "conf": "^7.1.2", + "crypto-js": "^4.1.1", + "electron-devtools-installer": "^3.2.0", + "electron-updater": "^4.6.5", + "electron-window-state": "^5.0.3", + "filehound": "^1.17.6", + "fs-extra": "^9.0.1", + "glob-to-regexp": "^0.4.1", + "got": "^11.8.6", + "grapheme-splitter": "^1.0.4", + "handlebars": "^4.7.7", + "history": "^4.10.1", + "hpagent": "^1.2.0", + "http-proxy": "^1.18.1", + "immer": "^9.0.17", + "joi": "^17.7.0", + "js-yaml": "^4.1.0", + "jsdom": "^16.7.0", + "lodash": "^4.17.15", + "marked": "^4.2.5", + "md5-file": "^5.0.0", + "mobx": "^6.7.0", + "mobx-observable-history": "^2.0.3", + "mobx-react": "^7.6.0", + "mobx-utils": "^6.0.4", + "moment": "^2.29.4", + "moment-timezone": "^0.5.40", + "node-fetch": "^3.3.0", + "node-pty": "0.10.1", + "npm": "^8.19.3", + "p-limit": "^3.1.0", + "path-to-regexp": "^6.2.0", + "proper-lockfile": "^4.1.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-material-ui-carousel": "^2.3.11", + "react-router": "^5.3.4", + "react-virtualized-auto-sizer": "^1.0.7", + "readable-stream": "^3.6.0", + "request": "^2.88.2", + "request-promise-native": "^1.0.9", + "rfc6902": "^5.0.1", + "selfsigned": "^2.1.1", + "semver": "^7.3.8", + "tar": "^6.1.13", + "tcp-port-used": "^1.0.2", + "tempy": "1.0.1", + "typed-regex": "^0.0.8", + "url-parse": "^1.5.10", + "uuid": "^8.3.2", + "win-ca": "^3.5.0", + "winston": "^3.8.2", + "winston-transport-browserconsole": "^1.0.5", + "ws": "^8.12.0", + "xterm-link-provider": "^1.3.1" + }, + "devDependencies": { + "@async-fn/jest": "1.6.4", + "@material-ui/core": "^4.12.3", + "@material-ui/icons": "^4.11.2", + "@material-ui/lab": "^4.0.0-alpha.60", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", + "@sentry/types": "^6.19.7", + "@swc/cli": "^0.1.59", + "@swc/core": "^1.3.25", + "@swc/jest": "^0.2.24", + "@testing-library/dom": "^7.31.2", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0", + "@types/byline": "^4.2.33", + "@types/chart.js": "^2.9.36", + "@types/circular-dependency-plugin": "5.0.5", + "@types/cli-progress": "^3.11.0", + "@types/color": "^3.0.3", + "@types/command-line-args": "^5.2.0", + "@types/crypto-js": "^3.1.47", + "@types/dompurify": "^2.4.0", + "@types/electron-devtools-installer": "^2.2.1", + "@types/fs-extra": "^9.0.13", + "@types/glob-to-regexp": "^0.4.1", + "@types/gunzip-maybe": "^1.4.0", + "@types/hapi__call": "^9.0.0", + "@types/hapi__subtext": "^7.0.0", + "@types/html-webpack-plugin": "^3.2.6", + "@types/http-proxy": "^1.17.9", + "@types/jest": "^28.1.6", + "@types/js-yaml": "^4.0.5", + "@types/jsdom": "^16.2.14", + "@types/lodash": "^4.14.191", + "@types/marked": "^4.0.8", + "@types/md5-file": "^4.0.2", + "@types/memorystream": "^0.3.0", + "@types/mini-css-extract-plugin": "^2.4.0", + "@types/mock-fs": "^4.13.1", + "@types/node": "^16.18.11", + "@types/proper-lockfile": "^4.1.2", + "@types/randomcolor": "^0.5.7", + "@types/react": "^17.0.45", + "@types/react-beautiful-dnd": "^13.1.3", + "@types/react-dom": "^17.0.16", + "@types/react-router": "^5.1.19", + "@types/react-router-dom": "^5.3.3", + "@types/react-table": "^7.7.14", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", + "@types/readable-stream": "^2.3.13", + "@types/request": "^2.48.7", + "@types/request-promise-native": "^1.0.18", + "@types/semver": "^7.3.13", + "@types/sharp": "^0.31.1", + "@types/tar": "^6.1.3", + "@types/tar-stream": "^2.2.2", + "@types/tcp-port-used": "^1.0.1", + "@types/tempy": "^0.3.0", + "@types/triple-beam": "^1.3.2", + "@types/url-parse": "^1.4.8", + "@types/uuid": "^8.3.4", + "@types/webpack": "^5.28.0", + "@types/webpack-dev-server": "^4.7.2", + "@types/webpack-node-externals": "^2.5.3", + "@typescript-eslint/eslint-plugin": "^5.48.1", + "@typescript-eslint/parser": "^5.48.1", + "adr": "^1.4.3", + "ansi_up": "^5.1.0", + "chalk": "^4.1.2", + "chart.js": "^2.9.4", + "circular-dependency-plugin": "^5.2.2", + "cli-progress": "^3.11.2", + "color": "^3.2.1", + "command-line-args": "^5.2.1", + "concurrently": "^7.6.0", + "css-loader": "^6.7.3", + "deepdash": "^5.3.9", + "dompurify": "^2.4.3", + "electron": "^19.1.9", + "electron-builder": "^23.6.0", + "electron-notarize": "^0.3.0", + "esbuild": "^0.16.14", + "esbuild-loader": "^2.20.0", + "eslint": "^8.31.0", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-react": "7.31.11", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-unused-imports": "^2.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.2", + "gunzip-maybe": "^1.4.2", + "html-webpack-plugin": "^5.5.0", + "identity-obj-proxy": "^3.0.0", + "ignore-loader": "^0.1.2", + "include-media": "^1.4.9", + "jest": "^28.1.3", + "jest-canvas-mock": "^2.3.1", + "jest-environment-jsdom": "^28.1.3", + "jest-mock-extended": "^2.0.9", + "make-plural": "^6.2.2", + "memfs": "^3.4.12", + "memorystream": "^0.3.1", + "mini-css-extract-plugin": "^2.7.2", + "mock-http": "^1.1.0", + "monaco-editor": "^0.29.1", + "monaco-editor-webpack-plugin": "^5.0.0", + "node-gyp": "^8.3.0", + "node-loader": "^2.0.0", + "nodemon": "^2.0.20", + "playwright": "^1.29.2", + "postcss": "^8.4.21", + "postcss-loader": "^6.2.1", + "query-string": "^7.1.3", + "randomcolor": "^0.6.2", + "react-beautiful-dnd": "^13.1.1", + "react-refresh": "^0.14.0", + "react-refresh-typescript": "^2.0.7", + "react-router-dom": "^5.3.4", + "react-select": "^5.7.0", + "react-select-event": "^5.5.1", + "react-table": "^7.8.0", + "react-window": "^1.8.8", + "sass": "^1.57.1", + "sass-loader": "^12.6.0", + "sharp": "^0.31.3", + "style-loader": "^3.3.1", + "tailwindcss": "^3.2.4", + "tar-stream": "^2.2.0", + "ts-loader": "^9.4.2", + "ts-node": "^10.9.1", + "type-fest": "^2.14.0", + "typed-emitter": "^1.4.0", + "typedoc": "0.23.24", + "typedoc-plugin-markdown": "^3.13.6", + "typescript": "^4.9.4", + "typescript-plugin-css-modules": "^3.4.0", + "webpack": "^5.75.0", + "webpack-cli": "^5.0.1", + "webpack-dev-server": "^4.11.1", + "webpack-node-externals": "^3.0.0", + "xterm": "^4.19.0", + "xterm-addon-fit": "^0.5.0" + }, + "peerDependencies": { + "@types/byline": "^4.2.33", + "@types/chart.js": "^2.9.36", + "@types/color": "^3.0.3", + "@types/crypto-js": "^3.1.47", + "@types/lodash": "^4.14.191", + "@types/proper-lockfile": "^4.1.2", + "@types/react-dom": "^17.0.16", + "@types/react-router-dom": "^5.3.3", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", + "@types/request-promise-native": "^1.0.18", + "@types/tar": "^6.1.3", + "@types/tcp-port-used": "^1.0.1", + "@types/url-parse": "^1.4.8", + "@types/uuid": "^8.3.4", + "monaco-editor": "^0.29.1", + "react-select": "^5.7.0", + "typed-emitter": "^1.4.0", + "xterm-addon-fit": "^0.5.0" + } +} diff --git a/packages/core/src/common/.gitkeep b/packages/core/src/common/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/src/common/__tests__/catalog-category-registry.test.ts b/packages/core/src/common/__tests__/catalog-category-registry.test.ts new file mode 100644 index 0000000000..7da9e1c7c7 --- /dev/null +++ b/packages/core/src/common/__tests__/catalog-category-registry.test.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CatalogCategorySpec } from "../catalog"; +import { CatalogCategory, CatalogCategoryRegistry } from "../catalog"; + +class TestCatalogCategoryRegistry extends CatalogCategoryRegistry { } + +class TestCatalogCategory extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Test Category", + icon: "", + }; + public spec: CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [], + names: { + kind: "Test", + }, + }; +} + +class TestCatalogCategory2 extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Test Category 2", + icon: "", + }; + public spec: CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [], + names: { + kind: "Test2", + }, + }; +} + +describe("CatalogCategoryRegistry", () => { + it("should remove only the category registered when running the disposer", () => { + const registry = new TestCatalogCategoryRegistry(); + + expect(registry.items.length).toBe(0); + + const d1 = registry.add(new TestCatalogCategory()); + const d2 = registry.add(new TestCatalogCategory2()); + + expect(registry.items.length).toBe(2); + + d1(); + expect(registry.items.length).toBe(1); + + d2(); + expect(registry.items.length).toBe(0); + }); + + it("doesn't return items that are filtered out", () => { + const registry = new TestCatalogCategoryRegistry(); + + registry.add(new TestCatalogCategory()); + registry.add(new TestCatalogCategory2()); + + expect(registry.items.length).toBe(2); + expect(registry.filteredItems.length).toBe(2); + + const disposer = registry.addCatalogCategoryFilter(category => category.metadata.name === "Test Category"); + + expect(registry.items.length).toBe(2); + expect(registry.filteredItems.length).toBe(1); + + const disposer2 = registry.addCatalogCategoryFilter(category => category.metadata.name === "foo"); + + expect(registry.items.length).toBe(2); + expect(registry.filteredItems.length).toBe(0); + + disposer(); + + expect(registry.items.length).toBe(2); + expect(registry.filteredItems.length).toBe(0); + + disposer2(); + + expect(registry.items.length).toBe(2); + expect(registry.filteredItems.length).toBe(2); + }); +}); diff --git a/packages/core/src/common/__tests__/catalog-entity.test.tsx b/packages/core/src/common/__tests__/catalog-entity.test.tsx new file mode 100644 index 0000000000..d0700b53d3 --- /dev/null +++ b/packages/core/src/common/__tests__/catalog-entity.test.tsx @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { CatalogCategory } from "../catalog"; +import type { CatalogCategorySpec } from "../catalog"; + +class TestCatalogCategoryWithoutBadge extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + + public metadata = { + name: "Test Category", + icon: "", + }; + + public spec: CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [], + names: { + kind: "Test", + }, + }; +} + +class TestCatalogCategoryWithBadge extends TestCatalogCategoryWithoutBadge { + getBadge() { + return (
Test Badge
); + } +} + +describe("CatalogCategory", () => { + it("returns name", () => { + const category = new TestCatalogCategoryWithoutBadge(); + + expect(category.getName()).toEqual("Test Category"); + }); + + it("doesn't return badge by default", () => { + const category = new TestCatalogCategoryWithoutBadge(); + + expect(category.getBadge()).toEqual(null); + }); + + it("returns a badge", () => { + const category = new TestCatalogCategoryWithBadge(); + + expect(category.getBadge()).toBeTruthy(); + }); +}); diff --git a/packages/core/src/common/__tests__/cluster-store.test.ts b/packages/core/src/common/__tests__/cluster-store.test.ts new file mode 100644 index 0000000000..9dd1a29a5b --- /dev/null +++ b/packages/core/src/common/__tests__/cluster-store.test.ts @@ -0,0 +1,359 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ClusterStore } from "../cluster-store/cluster-store"; +import type { GetCustomKubeConfigFilePath } from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; +import getCustomKubeConfigFilePathInjectable from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; +import clusterStoreInjectable from "../cluster-store/cluster-store.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import type { CreateCluster } from "../cluster/create-cluster-injection-token"; +import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import assert from "assert"; +import directoryForTempInjectable from "../app-paths/directory-for-temp/directory-for-temp.injectable"; +import kubectlBinaryNameInjectable from "../../main/kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../../main/kubectl/normalized-arch.injectable"; +import normalizedPlatformInjectable from "../vars/normalized-platform.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import type { WriteJsonSync } from "../fs/write-json-sync.injectable"; +import writeJsonSyncInjectable from "../fs/write-json-sync.injectable"; +import type { ReadFileSync } from "../fs/read-file-sync.injectable"; +import readFileSyncInjectable from "../fs/read-file-sync.injectable"; +import { readFileSync } from "fs"; +import type { WriteFileSync } from "../fs/write-file-sync.injectable"; +import writeFileSyncInjectable from "../fs/write-file-sync.injectable"; +import type { WriteBufferSync } from "../fs/write-buffer-sync.injectable"; +import writeBufferSyncInjectable from "../fs/write-buffer-sync.injectable"; + +// NOTE: this is intended to read the actual file system +const testDataIcon = readFileSync("test-data/cluster-store-migration-icon.png"); +const clusterServerUrl = "https://localhost"; +const kubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: ${clusterServerUrl} + name: test +contexts: +- context: + cluster: test + user: test + name: foo +- context: + cluster: test + user: test + name: foo2 +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; + +describe("cluster-store", () => { + let di: DiContainer; + let clusterStore: ClusterStore; + let createCluster: CreateCluster; + let writeJsonSync: WriteJsonSync; + let writeFileSync: WriteFileSync; + let writeBufferSync: WriteBufferSync; + let readFileSync: ReadFileSync; + let getCustomKubeConfigFilePath: GetCustomKubeConfigFilePath; + let writeFileSyncAndReturnPath: (filePath: string, contents: string) => string; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + di.override(directoryForTempInjectable, () => "/some-temp-directory"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); + createCluster = di.inject(createClusterInjectionToken); + getCustomKubeConfigFilePath = di.inject(getCustomKubeConfigFilePathInjectable); + writeJsonSync = di.inject(writeJsonSyncInjectable); + writeFileSync = di.inject(writeFileSyncInjectable); + writeBufferSync = di.inject(writeBufferSyncInjectable); + readFileSync = di.inject(readFileSyncInjectable); + writeFileSyncAndReturnPath = (filePath, contents) => (writeFileSync(filePath, contents), filePath); + }); + + describe("empty config", () => { + beforeEach(async () => { + writeJsonSync("/some-directory-for-user-data/lens-cluster-store.json", {}); + clusterStore = di.inject(clusterStoreInjectable); + clusterStore.load(); + }); + + describe("with foo cluster added", () => { + beforeEach(() => { + const cluster = createCluster({ + id: "foo", + contextName: "foo", + preferences: { + terminalCWD: "/some-directory-for-user-data", + icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", + clusterName: "minikube", + }, + kubeConfigPath: writeFileSyncAndReturnPath( + getCustomKubeConfigFilePath("foo"), + kubeconfig, + ), + }, { + clusterServerUrl, + }); + + clusterStore.addCluster(cluster); + }); + + it("adds new cluster to store", async () => { + const storedCluster = clusterStore.getById("foo"); + + assert(storedCluster); + + expect(storedCluster.id).toBe("foo"); + expect(storedCluster.preferences.terminalCWD).toBe("/some-directory-for-user-data"); + expect(storedCluster.preferences.icon).toBe( + "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", + ); + }); + }); + + describe("with prod and dev clusters added", () => { + beforeEach(() => { + const store = clusterStore; + + store.addCluster({ + id: "prod", + contextName: "foo", + preferences: { + clusterName: "prod", + }, + kubeConfigPath: writeFileSyncAndReturnPath( + getCustomKubeConfigFilePath("prod"), + kubeconfig, + ), + }); + store.addCluster({ + id: "dev", + contextName: "foo2", + preferences: { + clusterName: "dev", + }, + kubeConfigPath: writeFileSyncAndReturnPath( + getCustomKubeConfigFilePath("dev"), + kubeconfig, + ), + }); + }); + + it("check if store can contain multiple clusters", () => { + expect(clusterStore.hasClusters()).toBeTruthy(); + expect(clusterStore.clusters.size).toBe(2); + }); + + it("check if cluster's kubeconfig file saved", () => { + const file = writeFileSyncAndReturnPath(getCustomKubeConfigFilePath("boo"), "kubeconfig"); + + expect(readFileSync(file)).toBe("kubeconfig"); + }); + }); + }); + + describe("config with existing clusters", () => { + beforeEach(() => { + writeFileSync("/temp-kube-config", kubeconfig); + writeJsonSync("/some-directory-for-user-data/lens-cluster-store.json", { + __internal__: { + migrations: { + version: "99.99.99", + }, + }, + clusters: [ + { + id: "cluster1", + kubeConfigPath: "/temp-kube-config", + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "default", + }, + { + id: "cluster2", + kubeConfigPath: "/temp-kube-config", + contextName: "foo2", + preferences: { terminalCWD: "/foo2" }, + }, + { + id: "cluster3", + kubeConfigPath: "/temp-kube-config", + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "foo", + ownerRef: "foo", + }, + ], + }); + clusterStore = di.inject(clusterStoreInjectable); + clusterStore.load(); + }); + it("allows to retrieve a cluster", () => { + const storedCluster = clusterStore.getById("cluster1"); + + assert(storedCluster); + + expect(storedCluster.id).toBe("cluster1"); + expect(storedCluster.preferences.terminalCWD).toBe("/foo"); + }); + + it("allows getting all of the clusters", async () => { + const storedClusters = clusterStore.clustersList; + + expect(storedClusters.length).toBe(3); + expect(storedClusters[0].id).toBe("cluster1"); + expect(storedClusters[0].preferences.terminalCWD).toBe("/foo"); + expect(storedClusters[1].id).toBe("cluster2"); + expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2"); + expect(storedClusters[2].id).toBe("cluster3"); + }); + }); + + describe("config with invalid cluster kubeconfig", () => { + beforeEach(() => { + writeFileSync("/invalid-kube-config", invalidKubeconfig); + writeFileSync("/valid-kube-config", kubeconfig); + writeJsonSync("/some-directory-for-user-data/lens-cluster-store.json", { + __internal__: { + migrations: { + version: "99.99.99", + }, + }, + clusters: [ + { + id: "cluster1", + kubeConfigPath: "/invalid-kube-config", + contextName: "test", + preferences: { terminalCWD: "/foo" }, + workspace: "foo", + }, + { + id: "cluster2", + kubeConfigPath: "/valid-kube-config", + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "default", + }, + ], + }); + clusterStore = di.inject(clusterStoreInjectable); + clusterStore.load(); + }); + + it("does not enable clusters with invalid kubeconfig", () => { + const storedClusters = clusterStore.clustersList; + + expect(storedClusters.length).toBe(1); + }); + }); + + describe("pre 3.6.0-beta.1 config with an existing cluster", () => { + beforeEach(() => { + writeJsonSync("/some-directory-for-user-data/lens-cluster-store.json", { + __internal__: { + migrations: { + version: "3.5.0", + }, + }, + clusters: [ + { + id: "cluster1", + kubeConfig: minimalValidKubeConfig, + contextName: "cluster", + preferences: { + icon: "store://icon_path", + }, + }, + ], + }); + writeBufferSync("/some-directory-for-user-data/icon_path", testDataIcon); + + di.override(storeMigrationVersionInjectable, () => "3.6.0"); + + clusterStore = di.inject(clusterStoreInjectable); + clusterStore.load(); + }); + + it("migrates to modern format with kubeconfig in a file", async () => { + const config = clusterStore.clustersList[0].kubeConfigPath; + + expect(readFileSync(config)).toBe(minimalValidKubeConfig); + }); + + it("migrates to modern format with icon not in file", async () => { + expect(clusterStore.clustersList[0].preferences.icon).toMatch(/data:;base64,/); + }); + }); +}); + +const invalidKubeconfig = JSON.stringify({ + apiVersion: "v1", + clusters: [{ + cluster: { + server: "https://localhost", + }, + name: "test2", + }], + contexts: [{ + context: { + cluster: "test", + user: "test", + }, + name: "test", + }], + "current-context": "test", + kind: "Config", + preferences: {}, + users: [{ + user: { + token: "kubeconfig-user-q4lm4:xxxyyyy", + }, + name: "test", + }], +}); + +const minimalValidKubeConfig = JSON.stringify({ + apiVersion: "v1", + clusters: [ + { + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }, + ], + "current-context": "minikube", + contexts: [ + { + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }, + ], + users: [ + { + name: "minikube", + user: { + "client-certificate": "/Users/foo/.minikube/client.crt", + "client-key": "/Users/foo/.minikube/client.key", + }, + }, + ], + kind: "Config", + preferences: {}, +}); diff --git a/packages/core/src/common/__tests__/event-emitter.test.ts b/packages/core/src/common/__tests__/event-emitter.test.ts new file mode 100644 index 0000000000..cc71922182 --- /dev/null +++ b/packages/core/src/common/__tests__/event-emitter.test.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { EventEmitter } from "../event-emitter"; + +describe("EventEmitter", () => { + it("should stop early if a listener returns false", () => { + let called = false; + const e = new EventEmitter<[]>(); + + e.addListener(() => false, {}); + e.addListener(() => { called = true; }, {}); + e.emit(); + + expect(called).toBe(false); + }); + + it("shouldn't stop early if a listener returns 0", () => { + let called = false; + const e = new EventEmitter<[]>(); + + e.addListener(() => 0 as never, {}); + e.addListener(() => { called = true; }, {}); + e.emit(); + + expect(called).toBe(true); + }); + + it("prepended listeners should be called before others", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + + e.addListener(() => { callOrder.push(1); }, {}); + e.addListener(() => { callOrder.push(2); }, {}); + e.addListener(() => { callOrder.push(3); }, { prepend: true }); + e.emit(); + + expect(callOrder).toStrictEqual([3, 1, 2]); + }); + + it("once listeners should be called only once", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + + e.addListener(() => { callOrder.push(1); }, {}); + e.addListener(() => { callOrder.push(2); }, {}); + e.addListener(() => { callOrder.push(3); }, { once: true }); + e.emit(); + e.emit(); + + expect(callOrder).toStrictEqual([1, 2, 3, 1, 2]); + }); + + it("removeListener should stop the listener from being called", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + const r = () => { callOrder.push(3); }; + + e.addListener(() => { callOrder.push(1); }, {}); + e.addListener(() => { callOrder.push(2); }, {}); + e.addListener(r); + + e.emit(); + e.removeListener(r); + e.emit(); + + expect(callOrder).toStrictEqual([1, 2, 3, 1, 2]); + }); + + it("removeAllListeners should stop the all listeners from being called", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + + e.addListener(() => { callOrder.push(1); }); + e.addListener(() => { callOrder.push(2); }); + e.addListener(() => { callOrder.push(3); }); + + e.emit(); + e.removeAllListeners(); + e.emit(); + + expect(callOrder).toStrictEqual([1, 2, 3]); + }); +}); diff --git a/packages/core/src/common/__tests__/hotbar-store.test.ts b/packages/core/src/common/__tests__/hotbar-store.test.ts new file mode 100644 index 0000000000..ac8cecc1d2 --- /dev/null +++ b/packages/core/src/common/__tests__/hotbar-store.test.ts @@ -0,0 +1,356 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { anyObject } from "jest-mock-extended"; +import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import type { DiContainer } from "@ogre-tools/injectable"; +import hotbarStoreInjectable from "../hotbars/store.injectable"; +import type { HotbarStore } from "../hotbars/store"; +import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable"; +import { computed } from "mobx"; +import hasCategoryForEntityInjectable from "../catalog/has-category-for-entity.injectable"; +import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import loggerInjectable from "../logger.injectable"; +import type { Logger } from "../logger"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import writeJsonSyncInjectable from "../fs/write-json-sync.injectable"; + +function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { + return { + getName: jest.fn(() => data.metadata?.name), + getId: jest.fn(() => data.metadata?.uid), + getSource: jest.fn(() => data.metadata?.source ?? "unknown"), + isEnabled: jest.fn(() => data.status?.enabled ?? true), + onContextMenuOpen: jest.fn(), + onSettingsOpen: jest.fn(), + metadata: {}, + spec: {}, + status: {}, + ...data, + } as CatalogEntity; +} + +describe("HotbarStore", () => { + let di: DiContainer; + let hotbarStore: HotbarStore; + let testCluster: CatalogEntity; + let minikubeCluster: CatalogEntity; + let awsCluster: CatalogEntity; + let loggerMock: jest.Mocked; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + testCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-test-id", + name: "my-test-cluster", + source: "local", + labels: {}, + }, + }); + minikubeCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-minikube-id", + name: "my-minikube-cluster", + source: "local", + labels: {}, + }, + }); + awsCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-aws-id", + name: "my-aws-cluster", + source: "local", + labels: {}, + }, + }); + + di.override(hasCategoryForEntityInjectable, () => () => true); + + loggerMock = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + silly: jest.fn(), + }; + + di.override(loggerInjectable, () => loggerMock); + + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); + + catalogEntityRegistry.addComputedSource("some-id", computed(() => [ + testCluster, + minikubeCluster, + awsCluster, + catalogCatalogEntity, + ])); + }); + + describe("given no previous data in store, running all migrations", () => { + beforeEach(() => { + hotbarStore = di.inject(hotbarStoreInjectable); + + hotbarStore.load(); + }); + + describe("load", () => { + it("loads one hotbar by default", () => { + expect(hotbarStore.hotbars.length).toEqual(1); + }); + }); + + describe("add", () => { + it("adds a hotbar", () => { + hotbarStore.add({ name: "hottest" }); + expect(hotbarStore.hotbars.length).toEqual(2); + }); + }); + + describe("hotbar items", () => { + it("initially creates 12 empty cells", () => { + expect(hotbarStore.getActive().items.length).toEqual(12); + }); + + it("initially adds catalog entity as first item", () => { + expect(hotbarStore.getActive().items[0]?.entity.name).toEqual("Catalog"); + }); + + it("adds items", () => { + hotbarStore.addToHotbar(testCluster); + const items = hotbarStore.getActive().items.filter(Boolean); + + expect(items.length).toEqual(2); + }); + + it("removes items", () => { + hotbarStore.addToHotbar(testCluster); + hotbarStore.removeFromHotbar("some-test-id"); + hotbarStore.removeFromHotbar("catalog-entity"); + const items = hotbarStore.getActive().items.filter(Boolean); + + expect(items).toStrictEqual([]); + }); + + it("does nothing if removing with invalid uid", () => { + hotbarStore.addToHotbar(testCluster); + hotbarStore.removeFromHotbar("invalid uid"); + const items = hotbarStore.getActive().items.filter(Boolean); + + expect(items.length).toEqual(2); + }); + + it("moves item to empty cell", () => { + hotbarStore.addToHotbar(testCluster); + hotbarStore.addToHotbar(minikubeCluster); + hotbarStore.addToHotbar(awsCluster); + + expect(hotbarStore.getActive().items[6]).toBeNull(); + + hotbarStore.restackItems(1, 5); + + expect(hotbarStore.getActive().items[5]).toBeTruthy(); + expect(hotbarStore.getActive().items[5]?.entity.uid).toEqual("some-test-id"); + }); + + it("moves items down", () => { + hotbarStore.addToHotbar(testCluster); + hotbarStore.addToHotbar(minikubeCluster); + hotbarStore.addToHotbar(awsCluster); + + // aws -> catalog + hotbarStore.restackItems(3, 0); + + const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); + + expect(items.slice(0, 4)).toEqual(["some-aws-id", "catalog-entity", "some-test-id", "some-minikube-id"]); + }); + + it("moves items up", () => { + hotbarStore.addToHotbar(testCluster); + hotbarStore.addToHotbar(minikubeCluster); + hotbarStore.addToHotbar(awsCluster); + + // test -> aws + hotbarStore.restackItems(1, 3); + + const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); + + expect(items.slice(0, 4)).toEqual(["catalog-entity", "some-minikube-id", "some-aws-id", "some-test-id"]); + }); + + it("logs an error if cellIndex is out of bounds", () => { + hotbarStore.add({ name: "hottest", id: "hottest" }); + hotbarStore.setActiveHotbar("hottest"); + + hotbarStore.addToHotbar(testCluster, -1); + expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + + hotbarStore.addToHotbar(testCluster, 12); + expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + + hotbarStore.addToHotbar(testCluster, 13); + expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + }); + + it("throws an error if getId is invalid or returns not a string", () => { + expect(() => hotbarStore.addToHotbar({} as any)).toThrowError(TypeError); + expect(() => hotbarStore.addToHotbar({ getId: () => true } as any)).toThrowError(TypeError); + }); + + it("throws an error if getName is invalid or returns not a string", () => { + expect(() => hotbarStore.addToHotbar({ getId: () => "" } as any)).toThrowError(TypeError); + expect(() => hotbarStore.addToHotbar({ getId: () => "", getName: () => 4 } as any)).toThrowError(TypeError); + }); + + it("does nothing when item moved to same cell", () => { + hotbarStore.addToHotbar(testCluster); + hotbarStore.restackItems(1, 1); + + expect(hotbarStore.getActive().items[1]?.entity.uid).toEqual("some-test-id"); + }); + + it("new items takes first empty cell", () => { + hotbarStore.addToHotbar(testCluster); + hotbarStore.addToHotbar(awsCluster); + hotbarStore.restackItems(0, 3); + hotbarStore.addToHotbar(minikubeCluster); + + expect(hotbarStore.getActive().items[0]?.entity.uid).toEqual("some-minikube-id"); + }); + + it("throws if invalid arguments provided", () => { + hotbarStore.addToHotbar(testCluster); + + expect(() => hotbarStore.restackItems(-5, 0)).toThrow(); + expect(() => hotbarStore.restackItems(2, -1)).toThrow(); + expect(() => hotbarStore.restackItems(14, 1)).toThrow(); + expect(() => hotbarStore.restackItems(11, 112)).toThrow(); + }); + + it("checks if entity already pinned to hotbar", () => { + hotbarStore.addToHotbar(testCluster); + + expect(hotbarStore.isAddedToActive(testCluster)).toBeTruthy(); + expect(hotbarStore.isAddedToActive(awsCluster)).toBeFalsy(); + }); + }); + }); + + describe("given data from 5.0.0-beta.3 and version being 5.0.0-beta.10", () => { + beforeEach(() => { + const writeJsonSync = di.inject(writeJsonSyncInjectable); + + writeJsonSync("/some-directory-for-user-data/lens-hotbar-store.json", { + __internal__: { + migrations: { + version: "5.0.0-beta.3", + }, + }, + hotbars: [ + { + id: "3caac17f-aec2-4723-9694-ad204465d935", + name: "myhotbar", + items: [ + { + entity: { + uid: "some-aws-id", + }, + }, + { + entity: { + uid: "55b42c3c7ba3b04193416cda405269a5", + }, + }, + { + entity: { + uid: "176fd331968660832f62283219d7eb6e", + }, + }, + { + entity: { + uid: "61c4fb45528840ebad1badc25da41d14", + name: "user1-context", + source: "local", + }, + }, + { + entity: { + uid: "27d6f99fe9e7548a6e306760bfe19969", + name: "foo2", + source: "local", + }, + }, + null, + { + entity: { + uid: "c0b20040646849bb4dcf773e43a0bf27", + name: "multinode-demo", + source: "local", + }, + }, + null, + null, + null, + null, + null, + ], + }, + ], + }); + + di.override(storeMigrationVersionInjectable, () => "5.0.0-beta.10"); + + hotbarStore = di.inject(hotbarStoreInjectable); + + hotbarStore.load(); + }); + + it("allows to retrieve a hotbar", () => { + const hotbar = hotbarStore.findById("3caac17f-aec2-4723-9694-ad204465d935"); + + expect(hotbar?.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); + }); + + it("clears cells without entity", () => { + const items = hotbarStore.hotbars[0].items; + + expect(items[2]).toBeNull(); + }); + + it("adds extra data to cells with according entity", () => { + const items = hotbarStore.hotbars[0].items; + + expect(items[0]).toEqual({ + entity: { + name: "my-aws-cluster", + source: "local", + uid: "some-aws-id", + }, + }); + }); + }); +}); diff --git a/packages/core/src/common/__tests__/kube-helpers.test.ts b/packages/core/src/common/__tests__/kube-helpers.test.ts new file mode 100644 index 0000000000..ec466b5c56 --- /dev/null +++ b/packages/core/src/common/__tests__/kube-helpers.test.ts @@ -0,0 +1,235 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeConfig } from "@kubernetes/client-node"; +import { validateKubeConfig, loadConfigFromString } from "../kube-helpers"; + +const kubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test +contexts: +- context: + cluster: test + user: test + name: valid +- context: + cluster: test2 + user: test + name: invalidCluster +- context: + cluster: test + user: test2 + name: invalidUser +- context: + cluster: test + user: invalidExec + name: invalidExec +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + exec: + command: echo +- name: invalidExec + user: + exec: + command: foo +`; + +interface Kubeconfig { + apiVersion: string; + clusters: [{ + name: string; + cluster: { + server: string; + }; + }]; + contexts: [{ + context: { + cluster: string; + user: string; + }; + name: string; + }]; + users: [{ + name: string; + }]; + kind: string; + "current-context": string; + preferences: {}; +} + +let mockKubeConfig: Kubeconfig; + +describe("kube helpers", () => { + describe("validateKubeconfig", () => { + const kc = new KubeConfig(); + + beforeAll(() => { + kc.loadFromString(kubeconfig); + }); + describe("with default validation options", () => { + describe("with valid kubeconfig", () => { + it("does not return an error", () => { + expect(validateKubeConfig(kc, "valid")).toBeDefined(); + }); + }); + describe("with invalid context object", () => { + it("returns an error", () => { + expect(validateKubeConfig(kc, "invalid").error?.toString()).toEqual( + expect.stringContaining("No valid context object provided in kubeconfig for context 'invalid'"), + ); + }); + }); + + describe("with invalid cluster object", () => { + it("returns an error", () => { + expect(validateKubeConfig(kc, "invalidCluster").error?.toString()).toEqual( + expect.stringContaining("No valid cluster object provided in kubeconfig for context 'invalidCluster'"), + ); + }); + }); + + describe("with invalid user object", () => { + it("returns an error", () => { + expect(validateKubeConfig(kc, "invalidUser").error?.toString()).toEqual( + expect.stringContaining("No valid user object provided in kubeconfig for context 'invalidUser'"), + ); + }); + }); + }); + }); + + describe("pre-validate context object in kubeconfig tests", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Check logger.error() output", () => { + it("invalid yaml string", () => { + const invalidYAMLString = "fancy foo config"; + + expect(loadConfigFromString(invalidYAMLString).error).toBeInstanceOf(Error); + }); + it("empty contexts", () => { + const emptyContexts = `apiVersion: v1\ncontexts: []`; + + expect(loadConfigFromString(emptyContexts).error).toBeUndefined(); + }); + }); + + describe("Check valid kubeconfigs", () => { + beforeEach(() => { + mockKubeConfig = { + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + "current-context": "minikube", + preferences: {}, + }; + }); + + it("single context is ok", async () => { + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); + + expect(config.getCurrentContext()).toBe("minikube"); + }); + + it("multiple context is ok", async () => { + mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "cluster-2" }, name: "cluster-2" }); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); + + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(2); + }); + }); + + describe("Check invalid kubeconfigs", () => { + beforeEach(() => { + mockKubeConfig = { + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + "current-context": "minikube", + preferences: {}, + }; + }); + + it("empty name in context causes it to be removed", async () => { + mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "cluster-2" }, name: "" }); + expect(mockKubeConfig.contexts.length).toBe(2); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); + + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); + }); + + it("empty cluster in context causes it to be removed", async () => { + mockKubeConfig.contexts.push({ context: { cluster: "", user: "cluster-2" }, name: "cluster-2" }); + expect(mockKubeConfig.contexts.length).toBe(2); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); + + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); + }); + + it("empty user in context causes it to be removed", async () => { + mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "" }, name: "cluster-2" }); + expect(mockKubeConfig.contexts.length).toBe(2); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); + + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); + }); + + it("invalid context in between valid contexts is removed", async () => { + mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "" }, name: "cluster-2" }); + mockKubeConfig.contexts.push({ context: { cluster: "cluster-3", user: "cluster-3" }, name: "cluster-3" }); + expect(mockKubeConfig.contexts.length).toBe(3); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); + + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(2); + expect(config.contexts[0].name).toBe("minikube"); + expect(config.contexts[1].name).toBe("cluster-3"); + }); + }); + }); +}); diff --git a/packages/core/src/common/__tests__/timezones.test.ts b/packages/core/src/common/__tests__/timezones.test.ts new file mode 100644 index 0000000000..e7fa68e87c --- /dev/null +++ b/packages/core/src/common/__tests__/timezones.test.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +describe("Timezones", () => { + it("should always be UTC", () => { + expect(new Date().getTimezoneOffset()).toBe(0); + }); +}); + +export {}; diff --git a/packages/core/src/common/__tests__/user-store.test.ts b/packages/core/src/common/__tests__/user-store.test.ts new file mode 100644 index 0000000000..7071fc5c17 --- /dev/null +++ b/packages/core/src/common/__tests__/user-store.test.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { UserStore } from "../user-store"; +import userStoreInjectable from "../user-store/user-store.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import type { ClusterStoreModel } from "../cluster-store/cluster-store"; +import { defaultThemeId } from "../vars"; +import writeFileInjectable from "../fs/write-file.injectable"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import releaseChannelInjectable from "../vars/release-channel.injectable"; +import defaultUpdateChannelInjectable from "../../features/application-update/common/selected-update-channel/default-update-channel.injectable"; +import writeJsonSyncInjectable from "../fs/write-json-sync.injectable"; +import writeFileSyncInjectable from "../fs/write-file-sync.injectable"; + +describe("user store tests", () => { + let userStore: UserStore; + let di: DiContainer; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(writeFileInjectable, () => () => Promise.resolve()); + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + + di.override(releaseChannelInjectable, () => ({ + get: () => "latest" as const, + init: async () => {}, + })); + await di.inject(defaultUpdateChannelInjectable).init(); + + userStore = di.inject(userStoreInjectable); + }); + + describe("for an empty config", () => { + beforeEach(() => { + const writeJsonSync = di.inject(writeJsonSyncInjectable); + + writeJsonSync("/some-directory-for-user-data/lens-user-store.json", {}); + writeJsonSync("/some-directory-for-user-data/kube_config", {}); + + userStore.load(); + }); + + it("allows setting and getting preferences", () => { + userStore.httpsProxy = "abcd://defg"; + + expect(userStore.httpsProxy).toBe("abcd://defg"); + expect(userStore.colorTheme).toBe(defaultThemeId); + + userStore.colorTheme = "light"; + expect(userStore.colorTheme).toBe("light"); + }); + + it("correctly resets theme to default value", async () => { + userStore.colorTheme = "some other theme"; + userStore.resetTheme(); + expect(userStore.colorTheme).toBe(defaultThemeId); + }); + }); + + describe("migrations", () => { + beforeEach(() => { + const writeJsonSync = di.inject(writeJsonSyncInjectable); + const writeFileSync = di.inject(writeFileSyncInjectable); + + writeJsonSync("/some-directory-for-user-data/lens-user-store.json", { + preferences: { colorTheme: "light" }, + }); + + writeJsonSync("/some-directory-for-user-data/lens-cluster-store.json", { + clusters: [ + { + id: "foobar", + kubeConfigPath: "/some-directory-for-user-data/extension_data/foo/bar", + }, + { + id: "barfoo", + kubeConfigPath: "/some/other/path", + }, + ], + } as ClusterStoreModel); + + writeJsonSync("/some-directory-for-user-data/extension_data", {}); + + writeFileSync("/some/other/path", "is file"); + + di.override(storeMigrationVersionInjectable, () => "10.0.0"); + + userStore.load(); + }); + + it("skips clusters for adding to kube-sync with files under extension_data/", () => { + expect(userStore.syncKubeconfigEntries.has("/some-directory-for-user-data/extension_data/foo/bar")).toBe(false); + expect(userStore.syncKubeconfigEntries.has("/some/other/path")).toBe(true); + }); + + it("allows access to the colorTheme preference", () => { + expect(userStore.colorTheme).toBe("light"); + }); + }); +}); diff --git a/packages/core/src/common/app-event-bus/app-event-bus.injectable.ts b/packages/core/src/common/app-event-bus/app-event-bus.injectable.ts new file mode 100644 index 0000000000..3dee975f7b --- /dev/null +++ b/packages/core/src/common/app-event-bus/app-event-bus.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { EventEmitter } from "../event-emitter"; +import type { AppEvent } from "./event-bus"; + +const appEventBusInjectable = getInjectable({ + id: "app-event-bus", + instantiate: () => new EventEmitter<[AppEvent]>, + decorable: false, +}); + +export default appEventBusInjectable; diff --git a/packages/core/src/common/app-event-bus/emit-event.injectable.ts b/packages/core/src/common/app-event-bus/emit-event.injectable.ts new file mode 100644 index 0000000000..9c9194ceb8 --- /dev/null +++ b/packages/core/src/common/app-event-bus/emit-event.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appEventBusInjectable from "./app-event-bus.injectable"; +import type { AppEvent } from "./event-bus"; + +export type EmitAppEvent = (event: AppEvent) => void; + +const emitAppEventInjectable = getInjectable({ + id: "emit-app-event", + instantiate: (di): EmitAppEvent => { + const bus = di.inject(appEventBusInjectable); + + return (event) => bus.emit(event); + }, + decorable: false, +}); + +export default emitAppEventInjectable; diff --git a/packages/core/src/common/app-event-bus/event-bus.ts b/packages/core/src/common/app-event-bus/event-bus.ts new file mode 100644 index 0000000000..d121b842f7 --- /dev/null +++ b/packages/core/src/common/app-event-bus/event-bus.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Data for telemetry + */ +export interface AppEvent { + name: string; + action: string; + destination?: string; + params?: Record; +} diff --git a/packages/core/src/common/app-paths/app-path-injection-token.ts b/packages/core/src/common/app-paths/app-path-injection-token.ts new file mode 100644 index 0000000000..91e8a580d8 --- /dev/null +++ b/packages/core/src/common/app-paths/app-path-injection-token.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { PathName } from "./app-path-names"; + +export type AppPaths = Record; diff --git a/packages/core/src/common/app-paths/app-path-names.ts b/packages/core/src/common/app-paths/app-path-names.ts new file mode 100644 index 0000000000..8e3d2c440e --- /dev/null +++ b/packages/core/src/common/app-paths/app-path-names.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { app as electronApp } from "electron"; + +export type PathName = Parameters[0] | "currentApp"; + +export const pathNames: PathName[] = [ + "currentApp", + "home", + "appData", + "userData", + "cache", + "temp", + "exe", + "module", + "desktop", + "documents", + "downloads", + "music", + "pictures", + "videos", + "logs", + "crashDumps", + "recent", +]; diff --git a/packages/core/src/common/app-paths/app-paths-channel.ts b/packages/core/src/common/app-paths/app-paths-channel.ts new file mode 100644 index 0000000000..4502569d3b --- /dev/null +++ b/packages/core/src/common/app-paths/app-paths-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AppPaths } from "./app-path-injection-token"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export type AppPathsChannel = RequestChannel; + +export const appPathsChannel: AppPathsChannel = { + id: "app-paths", +}; + diff --git a/packages/core/src/common/app-paths/app-paths-state.injectable.ts b/packages/core/src/common/app-paths/app-paths-state.injectable.ts new file mode 100644 index 0000000000..5487d428b2 --- /dev/null +++ b/packages/core/src/common/app-paths/app-paths-state.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AppPaths } from "./app-path-injection-token"; + +const appPathsStateInjectable = getInjectable({ + id: "app-paths-state", + + instantiate: () => { + let state: AppPaths; + + return { + get: () =>{ + if (!state) { + throw new Error("Tried to get app paths before state is setupped."); + } + + return state; + }, + + set: (newState: AppPaths) => { + if (state) { + throw new Error("Tried to overwrite existing state of app paths."); + } + + state = newState; + }, + }; + }, +}); + +export default appPathsStateInjectable; diff --git a/packages/core/src/common/app-paths/app-paths.injectable.ts b/packages/core/src/common/app-paths/app-paths.injectable.ts new file mode 100644 index 0000000000..0e836a8514 --- /dev/null +++ b/packages/core/src/common/app-paths/app-paths.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsStateInjectable from "./app-paths-state.injectable"; + +const appPathsInjectable = getInjectable({ + id: "app-paths", + instantiate: (di) => di.inject(appPathsStateInjectable).get(), +}); + +export default appPathsInjectable; diff --git a/packages/core/src/common/app-paths/app-paths.test.ts b/packages/core/src/common/app-paths/app-paths.test.ts new file mode 100644 index 0000000000..6e645f96fa --- /dev/null +++ b/packages/core/src/common/app-paths/app-paths.test.ts @@ -0,0 +1,153 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AppPaths } from "./app-path-injection-token"; +import getElectronAppPathInjectable from "../../main/app-paths/get-electron-app-path/get-electron-app-path.injectable"; +import type { PathName } from "./app-path-names"; +import setElectronAppPathInjectable from "../../main/app-paths/set-electron-app-path/set-electron-app-path.injectable"; +import directoryForIntegrationTestingInjectable from "../../main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { DiContainer } from "@ogre-tools/injectable"; +import appPathsInjectable from "./app-paths.injectable"; + +describe("app-paths", () => { + let builder: ApplicationBuilder; + + beforeEach(() => { + builder = getApplicationBuilder(); + + const defaultAppPathsStub: AppPaths = { + currentApp: "/some-current-app", + appData: "/some-app-data", + cache: "/some-cache", + crashDumps: "/some-crash-dumps", + desktop: "/some-desktop", + documents: "/some-documents", + downloads: "/some-downloads", + exe: "/some-exe", + home: "/some-home-path", + logs: "/some-logs", + module: "/some-module", + music: "/some-music", + pictures: "/some-pictures", + recent: "/some-recent", + temp: "/some-temp", + videos: "/some-videos", + userData: "/some-irrelevant-user-data", + }; + + builder.beforeApplicationStart((mainDi) => { + mainDi.override( + getElectronAppPathInjectable, + () => + (key: PathName): string | null => + defaultAppPathsStub[key], + ); + + mainDi.override( + setElectronAppPathInjectable, + () => + (key: PathName, path: string): void => { + defaultAppPathsStub[key] = path; + }, + ); + }); + }); + + describe("normally", () => { + let windowDi: DiContainer; + let mainDi: DiContainer; + + beforeEach(async () => { + await builder.render(); + + windowDi = builder.applicationWindow.only.di; + mainDi = builder.mainDi; + }); + + it("given in renderer, when injecting app paths, returns application specific app paths", () => { + const actual = windowDi.inject(appPathsInjectable); + + expect(actual).toEqual({ + currentApp: "/some-current-app", + appData: "/some-app-data", + cache: "/some-cache", + crashDumps: "/some-crash-dumps", + desktop: "/some-desktop", + documents: "/some-documents", + downloads: "/some-downloads", + exe: "/some-exe", + home: "/some-home-path", + logs: "/some-logs", + module: "/some-module", + music: "/some-music", + pictures: "/some-pictures", + recent: "/some-recent", + temp: "/some-temp", + videos: "/some-videos", + userData: "/some-app-data/some-product-name", + }); + }); + + it("given in main, when injecting app paths, returns application specific app paths", () => { + const actual = mainDi.inject(appPathsInjectable); + + expect(actual).toEqual({ + currentApp: "/some-current-app", + appData: "/some-app-data", + cache: "/some-cache", + crashDumps: "/some-crash-dumps", + desktop: "/some-desktop", + documents: "/some-documents", + downloads: "/some-downloads", + exe: "/some-exe", + home: "/some-home-path", + logs: "/some-logs", + module: "/some-module", + music: "/some-music", + pictures: "/some-pictures", + recent: "/some-recent", + temp: "/some-temp", + videos: "/some-videos", + userData: "/some-app-data/some-product-name", + }); + }); + }); + + describe("when running integration tests", () => { + let windowDi: DiContainer; + + beforeEach(async () => { + builder.beforeApplicationStart((mainDi) => { + mainDi.override( + directoryForIntegrationTestingInjectable, + () => "/some-integration-testing-app-data", + ); + }); + + await builder.render(); + + windowDi = builder.applicationWindow.only.di; + }); + + it("given in renderer, when injecting path for app data, has integration specific app data path", () => { + const { appData, userData } = windowDi.inject(appPathsInjectable); + + expect({ appData, userData }).toEqual({ + appData: "/some-integration-testing-app-data", + userData: "/some-integration-testing-app-data/some-product-name", + }); + }); + + it("given in main, when injecting path for app data, has integration specific app data path", () => { + const { appData, userData } = windowDi.inject(appPathsInjectable); + + expect({ appData, userData }).toEqual({ + appData: "/some-integration-testing-app-data", + userData: "/some-integration-testing-app-data/some-product-name", + }); + }); + }); +}); diff --git a/packages/core/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts b/packages/core/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts new file mode 100644 index 0000000000..f8a55b041c --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; + +const directoryForBinariesInjectable = getInjectable({ + id: "directory-for-binaries", + + instantiate: (di) => { + const joinPaths = di.inject(joinPathsInjectable); + const directoryForUserData = di.inject(directoryForUserDataInjectable); + + return joinPaths(directoryForUserData, "binaries"); + }, +}); + +export default directoryForBinariesInjectable; diff --git a/packages/core/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts b/packages/core/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts new file mode 100644 index 0000000000..01f97dbaec --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsInjectable from "../app-paths.injectable"; + +const directoryForDownloadsInjectable = getInjectable({ + id: "directory-for-downloads", + instantiate: (di) => di.inject(appPathsInjectable).downloads, +}); + +export default directoryForDownloadsInjectable; diff --git a/packages/core/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts b/packages/core/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts new file mode 100644 index 0000000000..690f53d958 --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsInjectable from "../app-paths.injectable"; + +const directoryForExesInjectable = getInjectable({ + id: "directory-for-exes", + instantiate: (di) => di.inject(appPathsInjectable).exe, +}); + +export default directoryForExesInjectable; diff --git a/packages/core/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts b/packages/core/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts new file mode 100644 index 0000000000..d49029572e --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; + +const directoryForKubeConfigsInjectable = getInjectable({ + id: "directory-for-kube-configs", + + instantiate: (di) => { + const joinPaths = di.inject(joinPathsInjectable); + const directoryForUserData = di.inject(directoryForUserDataInjectable); + + return joinPaths(directoryForUserData, "kubeconfigs"); + }, +}); + +export default directoryForKubeConfigsInjectable; diff --git a/packages/core/src/common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts b/packages/core/src/common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts new file mode 100644 index 0000000000..c1ef1b23f5 --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForBinariesInjectable from "../directory-for-binaries/directory-for-binaries.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; + +const directoryForKubectlBinariesInjectable = getInjectable({ + id: "directory-for-kubectl-binaries", + + instantiate: (di) => { + const joinPaths = di.inject(joinPathsInjectable); + const directoryForBinaries = di.inject(directoryForBinariesInjectable); + + return joinPaths(directoryForBinaries, "kubectl"); + }, +}); + +export default directoryForKubectlBinariesInjectable; diff --git a/packages/core/src/common/app-paths/directory-for-logs.injectable.ts b/packages/core/src/common/app-paths/directory-for-logs.injectable.ts new file mode 100644 index 0000000000..e9abc35c44 --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-logs.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsInjectable from "./app-paths.injectable"; + +const directoryForLogsInjectable = getInjectable({ + id: "directory-for-logs", + instantiate: (di) => di.inject(appPathsInjectable).logs, +}); + +export default directoryForLogsInjectable; diff --git a/packages/core/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts b/packages/core/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts new file mode 100644 index 0000000000..460efc073d --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsInjectable from "../app-paths.injectable"; + +const directoryForTempInjectable = getInjectable({ + id: "directory-for-temp", + instantiate: (di) => di.inject(appPathsInjectable).temp, +}); + +export default directoryForTempInjectable; diff --git a/packages/core/src/common/app-paths/directory-for-user-data/directory-for-user-data.injectable.ts b/packages/core/src/common/app-paths/directory-for-user-data/directory-for-user-data.injectable.ts new file mode 100644 index 0000000000..0eb32221c6 --- /dev/null +++ b/packages/core/src/common/app-paths/directory-for-user-data/directory-for-user-data.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsInjectable from "../app-paths.injectable"; + +const directoryForUserDataInjectable = getInjectable({ + id: "directory-for-user-data", + instantiate: (di) => di.inject(appPathsInjectable).userData, +}); + +export default directoryForUserDataInjectable; diff --git a/packages/core/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts b/packages/core/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts new file mode 100644 index 0000000000..0580d372f1 --- /dev/null +++ b/packages/core/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForKubeConfigsInjectable from "../directory-for-kube-configs/directory-for-kube-configs.injectable"; +import joinPathsInjectable from "../../path/join-paths.injectable"; + +export type GetCustomKubeConfigFilePath = (fileName: string) => string; + +const getCustomKubeConfigFilePathInjectable = getInjectable({ + id: "get-custom-kube-config-directory", + + instantiate: (di): GetCustomKubeConfigFilePath => { + const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); + const joinPaths = di.inject(joinPathsInjectable); + + return (fileName) => joinPaths(directoryForKubeConfigs, fileName); + }, +}); + +export default getCustomKubeConfigFilePathInjectable; diff --git a/packages/core/src/common/app-paths/path-to-npm-cli.global-override-for-injectable.ts b/packages/core/src/common/app-paths/path-to-npm-cli.global-override-for-injectable.ts new file mode 100644 index 0000000000..db4734994d --- /dev/null +++ b/packages/core/src/common/app-paths/path-to-npm-cli.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import pathToNpmCliInjectable from "./path-to-npm-cli.injectable"; + +export default getGlobalOverride(pathToNpmCliInjectable, () => "/some/npm/cli/path"); diff --git a/packages/core/src/common/app-paths/path-to-npm-cli.injectable.ts b/packages/core/src/common/app-paths/path-to-npm-cli.injectable.ts new file mode 100644 index 0000000000..1c32b9ed6a --- /dev/null +++ b/packages/core/src/common/app-paths/path-to-npm-cli.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const pathToNpmCliInjectable = getInjectable({ + id: "path-to-npm-cli", + instantiate: () => __non_webpack_require__.resolve("npm"), + causesSideEffects: true, +}); + +export default pathToNpmCliInjectable; diff --git a/packages/core/src/common/base-store/base-store.ts b/packages/core/src/common/base-store/base-store.ts new file mode 100644 index 0000000000..9a786329e4 --- /dev/null +++ b/packages/core/src/common/base-store/base-store.ts @@ -0,0 +1,148 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type Config from "conf"; +import type { Migrations, Options as ConfOptions } from "conf/dist/source/types"; +import type { IEqualsComparer } from "mobx"; +import { makeObservable, reaction } from "mobx"; +import { disposer, isPromiseLike, toJS } from "../utils"; +import { broadcastMessage } from "../ipc"; +import isEqual from "lodash/isEqual"; +import { kebabCase } from "lodash"; +import type { GetConfigurationFileModel } from "../get-configuration-file-model/get-configuration-file-model.injectable"; +import type { Logger } from "../logger"; +import type { PersistStateToConfig } from "./save-to-file"; +import type { GetBasenameOfPath } from "../path/get-basename.injectable"; +import type { EnlistMessageChannelListener } from "../utils/channel/enlist-message-channel-listener-injection-token"; + +export interface BaseStoreParams extends Omit, "migrations"> { + syncOptions?: { + fireImmediately?: boolean; + equals?: IEqualsComparer; + }; + configName: string; +} + +export interface IpcChannelPrefixes { + local: string; + remote: string; +} + +export interface BaseStoreDependencies { + readonly logger: Logger; + readonly storeMigrationVersion: string; + readonly directoryForUserData: string; + readonly migrations: Migrations>; + readonly ipcChannelPrefixes: IpcChannelPrefixes; + readonly shouldDisableSyncInListener: boolean; + getConfigurationFileModel: GetConfigurationFileModel; + persistStateToConfig: PersistStateToConfig; + getBasenameOfPath: GetBasenameOfPath; + enlistMessageChannelListener: EnlistMessageChannelListener; +} + +/** + * Note: T should only contain base JSON serializable types. + */ +export abstract class BaseStore { + private readonly syncDisposers = disposer(); + + readonly displayName = kebabCase(this.params.configName).toUpperCase(); + + protected constructor( + protected readonly dependencies: BaseStoreDependencies, + protected readonly params: BaseStoreParams, + ) { + makeObservable(this); + } + + /** + * This must be called after the last child's constructor is finished (or just before it finishes) + */ + load() { + this.dependencies.logger.info(`[${this.displayName}]: LOADING ...`); + + const config = this.dependencies.getConfigurationFileModel({ + projectName: "lens", + projectVersion: this.dependencies.storeMigrationVersion, + cwd: this.cwd(), + ...this.params, + migrations: this.dependencies.migrations as Migrations, + }); + + const res = this.fromStore(config.store); + + if (isPromiseLike(res)) { + this.dependencies.logger.error(`${this.displayName} extends BaseStore's fromStore method returns a Promise or promise-like object. This is an error and must be fixed.`); + } + + this.startSyncing(config); + this.dependencies.logger.info(`[${this.displayName}]: LOADED from ${config.path}`); + } + + protected cwd() { + return this.dependencies.directoryForUserData; + } + + private startSyncing(config: Config) { + const name = this.dependencies.getBasenameOfPath(config.path); + + const disableSync = () => this.syncDisposers(); + const enableSync = () => { + this.syncDisposers.push( + reaction( + () => toJS(this.toJSON()), // unwrap possible observables and react to everything + model => { + this.dependencies.persistStateToConfig(config, model); + broadcastMessage(`${this.dependencies.ipcChannelPrefixes.remote}:${config.path}`, model); + }, + this.params.syncOptions, + ), + this.dependencies.enlistMessageChannelListener({ + channel: { + id: `${this.dependencies.ipcChannelPrefixes.local}:${config.path}`, + }, + handler: (model) => { + this.dependencies.logger.silly(`[${this.displayName}]: syncing ${name}`, { model }); + + if (this.dependencies.shouldDisableSyncInListener) { + disableSync(); + } + + // todo: use "resourceVersion" if merge required (to avoid equality checks => better performance) + if (!isEqual(this.toJSON(), model)) { + this.fromStore(model as T); + } + + if (this.dependencies.shouldDisableSyncInListener) { + enableSync(); + } + }, + }), + ); + }; + + enableSync(); + } + + /** + * fromStore is called internally when a child class syncs with the file + * system. + * + * Note: This function **must** be synchronous. + * + * @param data the parsed information read from the stored JSON file + */ + protected abstract fromStore(data: T): void; + + /** + * toJSON is called when syncing the store to the filesystem. It should + * produce a JSON serializable object representation of the current state. + * + * It is recommended that a round trip is valid. Namely, calling + * `this.fromStore(this.toJSON())` shouldn't change the state. + */ + abstract toJSON(): T; +} diff --git a/packages/core/src/common/base-store/channel-prefix.ts b/packages/core/src/common/base-store/channel-prefix.ts new file mode 100644 index 0000000000..f2662c65e0 --- /dev/null +++ b/packages/core/src/common/base-store/channel-prefix.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IpcChannelPrefixes } from "./base-store"; + +export const baseStoreIpcChannelPrefixesInjectionToken = getInjectionToken({ + id: "base-store-ipc-channel-prefix-token", +}); diff --git a/packages/core/src/common/base-store/disable-sync.ts b/packages/core/src/common/base-store/disable-sync.ts new file mode 100644 index 0000000000..ce7abd16a1 --- /dev/null +++ b/packages/core/src/common/base-store/disable-sync.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const shouldBaseStoreDisableSyncInIpcListenerInjectionToken = getInjectionToken({ + id: "should-base-store-disable-sync-in-ipc-listener-token", +}); diff --git a/packages/core/src/common/base-store/migrations.injectable.ts b/packages/core/src/common/base-store/migrations.injectable.ts new file mode 100644 index 0000000000..27f7489dfa --- /dev/null +++ b/packages/core/src/common/base-store/migrations.injectable.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { InjectionToken } from "@ogre-tools/injectable"; +import { lifecycleEnum, getInjectable } from "@ogre-tools/injectable"; +import type Conf from "conf/dist/source"; +import type { Migrations } from "conf/dist/source/types"; +import loggerInjectable from "../logger.injectable"; +import { getOrInsert, iter } from "../utils"; + +export interface MigrationDeclaration { + version: string; + run(store: Conf>>): void; +} + +const storeMigrationsInjectable = getInjectable({ + id: "store-migrations", + instantiate: (di, token): Migrations> => { + const logger = di.inject(loggerInjectable); + const declarations = di.injectMany(token); + const migrations = new Map(); + + for (const decl of declarations) { + getOrInsert(migrations, decl.version, []).push(decl.run); + } + + return Object.fromEntries( + iter.map( + migrations, + ([v, fns]) => [v, (store) => { + logger.info(`Running ${v} migration for ${store.path}`); + + for (const fn of fns) { + fn(store); + } + }], + ), + ); + }, + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, token: InjectionToken) => token.id, + }), +}); + +export default storeMigrationsInjectable; diff --git a/packages/core/src/common/base-store/save-to-file.ts b/packages/core/src/common/base-store/save-to-file.ts new file mode 100644 index 0000000000..b4d21ea950 --- /dev/null +++ b/packages/core/src/common/base-store/save-to-file.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type Config from "conf"; + +export type PersistStateToConfig = (config: Config, state: T) => void; + +export const persistStateToConfigInjectionToken = getInjectionToken({ + id: "persist-state-to-config-token", +}); diff --git a/packages/core/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts b/packages/core/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts new file mode 100644 index 0000000000..b2814f9785 --- /dev/null +++ b/packages/core/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import kubernetesClusterCategoryInjectable from "../../catalog/categories/kubernetes-cluster.injectable"; +import type { KubernetesClusterCategory } from "../kubernetes-cluster"; + + +describe("kubernetesClusterCategory", () => { + let kubernetesClusterCategory: KubernetesClusterCategory; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + kubernetesClusterCategory = di.inject(kubernetesClusterCategoryInjectable); + }); + + describe("filteredItems", () => { + const item1 = { + icon: "Icon", + title: "Title", + onClick: () => {}, + }; + const item2 = { + icon: "Icon 2", + title: "Title 2", + onClick: () => {}, + }; + + it("returns all items if no filter set", () => { + expect(kubernetesClusterCategory.filteredItems([item1, item2])).toEqual([item1, item2]); + }); + + it("returns filtered items", () => { + expect(kubernetesClusterCategory.filteredItems([item1, item2])).toEqual([item1, item2]); + + const disposer1 = kubernetesClusterCategory.addMenuFilter(item => item.icon === "Icon"); + + expect(kubernetesClusterCategory.filteredItems([item1, item2])).toEqual([item1]); + + const disposer2 = kubernetesClusterCategory.addMenuFilter(item => item.title === "Title 2"); + + expect(kubernetesClusterCategory.filteredItems([item1, item2])).toEqual([]); + + disposer1(); + + expect(kubernetesClusterCategory.filteredItems([item1, item2])).toEqual([item2]); + + disposer2(); + + expect(kubernetesClusterCategory.filteredItems([item1, item2])).toEqual([item1, item2]); + }); + }); +}); diff --git a/packages/core/src/common/catalog-entities/general-catalog-entities/general-catalog-entity-injection-token.ts b/packages/core/src/common/catalog-entities/general-catalog-entities/general-catalog-entity-injection-token.ts new file mode 100644 index 0000000000..ad743802bf --- /dev/null +++ b/packages/core/src/common/catalog-entities/general-catalog-entities/general-catalog-entity-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { GeneralEntity } from "../index"; + +export const generalCatalogEntityInjectionToken = getInjectionToken({ + id: "general-catalog-entity-injection-token", +}); diff --git a/packages/core/src/common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable.ts b/packages/core/src/common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable.ts new file mode 100644 index 0000000000..15195a4b74 --- /dev/null +++ b/packages/core/src/common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { generalCatalogEntityInjectionToken } from "../general-catalog-entity-injection-token"; +import { GeneralEntity } from "../../index"; +import { buildURL } from "../../../utils/buildUrl"; +import catalogRouteInjectable from "../../../front-end-routing/routes/catalog/catalog-route.injectable"; + +const catalogCatalogEntityInjectable = getInjectable({ + id: "general-catalog-entity-for-catalog", + + instantiate: (di) => { + const route = di.inject(catalogRouteInjectable); + const url = buildURL(route.path); + + return new GeneralEntity({ + metadata: { + uid: "catalog-entity", + name: "Catalog", + source: "app", + labels: {}, + }, + spec: { + path: url, + icon: { + material: "view_list", + background: "#3d90ce", + }, + }, + status: { + phase: "active", + }, + }); + }, + + injectionToken: generalCatalogEntityInjectionToken, +}); + +export default catalogCatalogEntityInjectable; diff --git a/packages/core/src/common/catalog-entities/general-catalog-entities/implementations/welcome-catalog-entity.injectable.ts b/packages/core/src/common/catalog-entities/general-catalog-entities/implementations/welcome-catalog-entity.injectable.ts new file mode 100644 index 0000000000..363dd73c5f --- /dev/null +++ b/packages/core/src/common/catalog-entities/general-catalog-entities/implementations/welcome-catalog-entity.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { generalCatalogEntityInjectionToken } from "../general-catalog-entity-injection-token"; +import { GeneralEntity } from "../../index"; +import { buildURL } from "../../../utils/buildUrl"; +import welcomeRouteInjectable from "../../../front-end-routing/routes/welcome/welcome-route.injectable"; + +const welcomeCatalogEntityInjectable = getInjectable({ + id: "general-catalog-entity-for-welcome", + + instantiate: (di) => { + const route = di.inject(welcomeRouteInjectable); + const url = buildURL(route.path); + + return new GeneralEntity({ + metadata: { + uid: "welcome-page-entity", + name: "Welcome Page", + source: "app", + labels: {}, + }, + spec: { + path: url, + icon: { + material: "meeting_room", + background: "#3d90ce", + }, + }, + status: { + phase: "active", + }, + }); + }, + + injectionToken: generalCatalogEntityInjectionToken, +}); + +export default welcomeCatalogEntityInjectable; diff --git a/packages/core/src/common/catalog-entities/general.ts b/packages/core/src/common/catalog-entities/general.ts new file mode 100644 index 0000000000..e81e59cc16 --- /dev/null +++ b/packages/core/src/common/catalog-entities/general.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../catalog"; +import type { CatalogEntityActionContext } from "../catalog/catalog-entity"; +import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog/catalog-entity"; + +interface GeneralEntitySpec extends CatalogEntitySpec { + path: string; + icon?: { + material?: string; + background?: string; + }; +} + +export class GeneralEntity extends CatalogEntity { + public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public readonly kind = "General"; + + async onRun(context: CatalogEntityActionContext) { + context.navigate(this.spec.path); + } +} + +export class GeneralCategory extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "General", + icon: "settings", + }; + public spec = { + group: "entity.k8slens.dev", + versions: [ + categoryVersion("v1alpha1", GeneralEntity), + ], + names: { + kind: "General", + }, + }; +} diff --git a/packages/core/src/common/catalog-entities/icons/kubernetes.svg b/packages/core/src/common/catalog-entities/icons/kubernetes.svg new file mode 100644 index 0000000000..b4a9068420 --- /dev/null +++ b/packages/core/src/common/catalog-entities/icons/kubernetes.svg @@ -0,0 +1,46 @@ + + + + + + diff --git a/packages/core/src/common/catalog-entities/index.ts b/packages/core/src/common/catalog-entities/index.ts new file mode 100644 index 0000000000..336a8c0f9a --- /dev/null +++ b/packages/core/src/common/catalog-entities/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./general"; +export * from "./kubernetes-cluster"; +export * from "./web-link"; diff --git a/packages/core/src/common/catalog-entities/kubernetes-cluster.ts b/packages/core/src/common/catalog-entities/kubernetes-cluster.ts new file mode 100644 index 0000000000..d47ab47831 --- /dev/null +++ b/packages/core/src/common/catalog-entities/kubernetes-cluster.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus, CatalogCategorySpec } from "../catalog"; +import { CatalogEntity, CatalogCategory, categoryVersion } from "../catalog/catalog-entity"; +import { broadcastMessage } from "../ipc"; +import { app } from "electron"; +import type { CatalogEntityConstructor, CatalogEntitySpec } from "../catalog/catalog-entity"; +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import { requestClusterActivation, requestClusterDisconnection } from "../../renderer/ipc"; +import KubeClusterCategoryIcon from "./icons/kubernetes.svg"; +import getClusterByIdInjectable from "../cluster-store/get-by-id.injectable"; +import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; + +export interface KubernetesClusterPrometheusMetrics { + address?: { + namespace: string; + service: string; + port: number; + prefix: string; + }; + type?: string; +} + +export interface KubernetesClusterSpec extends CatalogEntitySpec { + kubeconfigPath: string; + kubeconfigContext: string; + metrics?: { + source: string; + prometheus?: KubernetesClusterPrometheusMetrics; + }; + icon?: { + // TODO: move to CatalogEntitySpec once any-entity icons are supported + src?: string; + material?: string; + background?: string; + }; + accessibleNamespaces?: string[]; +} + +export enum LensKubernetesClusterStatus { + DELETING = "deleting", + CONNECTING = "connecting", + CONNECTED = "connected", + DISCONNECTED = "disconnected", +} + +export interface KubernetesClusterMetadata extends CatalogEntityMetadata { + distro?: string; + kubeVersion?: string; +} + +/** + * @deprecated This is no longer used as it is incorrect. Other sources can add more values + */ +export type KubernetesClusterStatusPhase = "connected" | "connecting" | "disconnected" | "deleting"; + +export interface KubernetesClusterStatus extends CatalogEntityStatus { +} + +export function isKubernetesCluster(item: unknown): item is KubernetesCluster { + return item instanceof KubernetesCluster; +} + +export class KubernetesCluster< + Metadata extends KubernetesClusterMetadata = KubernetesClusterMetadata, + Status extends KubernetesClusterStatus = KubernetesClusterStatus, + Spec extends KubernetesClusterSpec = KubernetesClusterSpec, +> extends CatalogEntity { + public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1"; + public static readonly kind: string = "KubernetesCluster"; + + public readonly apiVersion = KubernetesCluster.apiVersion; + public readonly kind = KubernetesCluster.kind; + + async connect(): Promise { + if (app) { + const di = getLegacyGlobalDiForExtensionApi(); + const getClusterById = di.inject(getClusterByIdInjectable); + + await getClusterById(this.getId())?.activate(); + } else { + await requestClusterActivation(this.getId(), false); + } + } + + async disconnect(): Promise { + if (app) { + const di = getLegacyGlobalDiForExtensionApi(); + const getClusterById = di.inject(getClusterByIdInjectable); + + getClusterById(this.getId())?.disconnect(); + } else { + await requestClusterDisconnection(this.getId(), false); + } + } + + async onRun(context: CatalogEntityActionContext) { + context.navigate(`/cluster/${this.getId()}`); + } + + onDetailsOpen(): void { + // + } + + onSettingsOpen(): void { + // + } + + onContextMenuOpen(context: CatalogEntityContextMenuContext) { + if (!this.metadata.source || this.metadata.source === "local") { + context.menuItems.push({ + title: "Settings", + icon: "settings", + onClick: () => broadcastMessage( + IpcRendererNavigationEvents.NAVIGATE_IN_APP, + `/entity/${this.getId()}/settings`, + ), + }); + } + + switch (this.status.phase) { + case LensKubernetesClusterStatus.CONNECTED: + case LensKubernetesClusterStatus.CONNECTING: + context.menuItems.push({ + title: "Disconnect", + icon: "link_off", + onClick: () => requestClusterDisconnection(this.getId()), + }); + break; + case LensKubernetesClusterStatus.DISCONNECTED: + context.menuItems.push({ + title: "Connect", + icon: "link", + onClick: () => context.navigate(`/cluster/${this.getId()}`), + }); + break; + } + } +} + +export class KubernetesClusterCategory extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Clusters", + icon: KubeClusterCategoryIcon, + }; + public spec: CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [ + categoryVersion("v1alpha1", KubernetesCluster as CatalogEntityConstructor), + ], + names: { + kind: "KubernetesCluster", + }, + }; +} diff --git a/packages/core/src/common/catalog-entities/web-link.ts b/packages/core/src/common/catalog-entities/web-link.ts new file mode 100644 index 0000000000..7c83051c8b --- /dev/null +++ b/packages/core/src/common/catalog-entities/web-link.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import type { CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog/catalog-entity"; +import productNameInjectable from "../vars/product-name.injectable"; +import weblinkStoreInjectable from "../weblinks-store/weblink-store.injectable"; + +export type WebLinkStatusPhase = "available" | "unavailable"; + +export interface WebLinkStatus extends CatalogEntityStatus { + phase: WebLinkStatusPhase; +} + +export interface WebLinkSpec { + url: string; +} + +export class WebLink extends CatalogEntity { + public static readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public static readonly kind = "WebLink"; + + public readonly apiVersion = WebLink.apiVersion; + public readonly kind = WebLink.kind; + + async onRun() { + window.open(this.spec.url, "_blank"); + } + + onContextMenuOpen(context: CatalogEntityContextMenuContext) { + // NOTE: this is safe because `onContextMenuOpen` is only supposed to be called in the renderer + const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.renderer); + const productName = di.inject(productNameInjectable); + const weblinkStore = di.inject(weblinkStoreInjectable); + + if (this.metadata.source === "local") { + context.menuItems.push({ + title: "Delete", + icon: "delete", + onClick: async () => weblinkStore.removeById(this.getId()), + confirm: { + message: `Remove Web Link "${this.getName()}" from ${productName}?`, + }, + }); + } + } +} + +export class WebLinkCategory extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Web Links", + icon: "public", + }; + public spec = { + group: "entity.k8slens.dev", + versions: [ + categoryVersion("v1alpha1", WebLink), + ], + names: { + kind: "WebLink", + }, + }; +} diff --git a/packages/core/src/common/catalog/catalog-entity.ts b/packages/core/src/common/catalog/catalog-entity.ts new file mode 100644 index 0000000000..7effe60f2f --- /dev/null +++ b/packages/core/src/common/catalog/catalog-entity.ts @@ -0,0 +1,413 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import EventEmitter from "events"; +import type TypedEmitter from "typed-emitter"; +import { observable, makeObservable } from "mobx"; +import { once } from "lodash"; +import type { Disposer } from "../utils"; +import { iter } from "../utils"; +import type { CategoryColumnRegistration } from "../../renderer/components/+catalog/custom-category-columns"; + +export type CatalogEntityDataFor = Entity extends CatalogEntity + ? CatalogEntityData + : never; + +export type CatalogEntityInstanceFrom = Constructor extends CatalogEntityConstructor + ? Entity + : never; + +export type CatalogEntityConstructor = ( + new (data: CatalogEntityDataFor) => Entity +); + +export interface CatalogCategoryVersion { + /** + * The specific version that the associated constructor is for. This MUST be + * a DNS label and SHOULD be of the form `vN`, `vNalphaY`, or `vNbetaY` where + * `N` and `Y` are both integers greater than 0. + * + * Examples: The following are valid values for this field. + * - `v1` + * - `v1beta1` + * - `v1alpha2` + * - `v3beta2` + */ + readonly name: string; + + /** + * The constructor for the entities. + */ + readonly entityClass: CatalogEntityConstructor; +} + +export interface CatalogCategorySpec { + /** + * The grouping for for the category. This MUST be a DNS label. + */ + readonly group: string; + + /** + * The specific versions of the constructors. + * + * NOTE: the field `.apiVersion` after construction MUST match `{.group}/{.versions.[] | .name}`. + * For example, if `group = "entity.k8slens.dev"` and there is an entry in `.versions` with + * `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1` + */ + readonly versions: CatalogCategoryVersion[]; + + /** + * This is the concerning the category + */ + readonly names: { + /** + * The kind of entity that this category is for. This value MUST be a DNS + * label and MUST be equal to the `kind` fields that are produced by the + * `.versions.[] | .entityClass` fields. + */ + readonly kind: string; + }; + + /** + * These are the columns used for displaying entities when in the catalog. + * + * If this is not provided then some default columns will be used, similar in + * scope to the columns in the "Browse" view. + * + * Even if you provide columns, a "Name" column will be provided as well with + * `priority: 0`. + * + * These columns will not be used in the "Browse" view. + */ + readonly displayColumns?: CategoryColumnRegistration[]; +} + +/** + * If the filter return a thruthy value, the menu item is displayed + */ +export type AddMenuFilter = (menu: CatalogEntityAddMenu) => any; + +export interface CatalogCategoryEvents { + /** + * This event will be emitted when the category is loaded in the catalog + * view. + */ + load: () => void; + + /** + * This event will be emitted when the catalog add menu is opened and is the + * way to added entries to that menu. + */ + catalogAddMenu: (context: CatalogEntityAddMenuContext) => void; + + /** + * This event will be emitted when the context menu for an entity is declared + * by this category is opened. + */ + contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; +} + +export interface CatalogCategoryMetadata { + /** + * The name of your category. The category can be searched for by this + * value. This will also be used for the catalog menu. + */ + readonly name: string; + /** + * Either an `` or the name of an icon from {@link IconProps} + */ + readonly icon: string; +} + +export function categoryVersion< + T extends CatalogEntity, + Metadata extends CatalogEntityMetadata, + Status extends CatalogEntityStatus, + Spec extends CatalogEntitySpec, +>(name: string, entityClass: new (data: CatalogEntityData) => T): CatalogCategoryVersion { + return { + name, + entityClass: entityClass as CatalogEntityConstructor, + }; +} + +export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter) { + /** + * The version of category that you are wanting to declare. + * + * Currently supported values: + * + * - `"catalog.k8slens.dev/v1alpha1"` + */ + abstract readonly apiVersion: string; + + /** + * The kind of item you wish to declare. + * + * Currently supported values: + * + * - `"CatalogCategory"` + */ + abstract readonly kind: string; + + /** + * The data about the category itself + */ + abstract readonly metadata: CatalogCategoryMetadata; + + /** + * The most important part of a category, as it is where entity versions are declared. + */ + abstract readonly spec: CatalogCategorySpec; + + /** + * @internal + */ + protected readonly filters = observable.set([], { + deep: false, + }); + + /** + * Parse a category ID into parts. + * @param id The id of a category is parse + * @returns The group and kind parts of the ID + */ + public static parseId(id: string): { group?: string; kind?: string } { + const [group, kind] = id.split("/") ?? []; + + return { group, kind }; + } + + /** + * Get the ID of this category + */ + public getId(): string { + return `${this.spec.group}/${this.spec.names.kind}`; + } + + /** + * Get the name of this category + */ + public getName(): string { + return this.metadata.name; + } + + /** + * Get the badge of this category. + * Defaults to no badge. + * The badge is displayed next to the Category name in the Catalog Category menu + */ + public getBadge(): React.ReactNode { + return null; + } + + /** + * Add a filter for menu items of catalogAddMenu + * @param fn The function that should return a truthy value if that menu item should be displayed + * @returns A function to remove that filter + */ + public addMenuFilter(fn: AddMenuFilter): Disposer { + this.filters.add(fn); + + return once(() => void this.filters.delete(fn)); + } + + /** + * Filter menuItems according to the Category's set filters + * @param menuItems menu items to filter + * @returns filtered menu items + */ + public filteredItems(menuItems: CatalogEntityAddMenu[]) { + return Array.from( + iter.reduce( + this.filters, + iter.filter, + menuItems.values(), + ), + ); + } +} + +export type EntityMetadataObject = { [Key in string]?: EntityMetadataValue }; +export type EntityMetadataValue = string | number | boolean | EntityMetadataObject | undefined; + +export interface CatalogEntityMetadata extends EntityMetadataObject { + uid: string; + name: string; + shortName?: string; + description?: string; + source?: string; + labels: Record; +} + +export interface CatalogEntityStatus { + phase: string; + reason?: string; + + /** + * @default true + */ + enabled?: boolean; + message?: string; + active?: boolean; +} + +export interface CatalogEntityActionContext { + navigate: (url: string) => void; + setCommandPaletteContext: (context?: CatalogEntity) => void; +} + +export interface CatalogEntityContextMenu { + /** + * Menu title + */ + title: string; + /** + * Menu icon + */ + icon?: string; + /** + * OnClick handler + */ + onClick: () => void | Promise; + /** + * Confirm click with a message + */ + confirm?: { + message: string; + }; +} + +export interface CatalogEntityAddMenu extends CatalogEntityContextMenu { + icon: string; + defaultAction?: boolean; +} + +export interface CatalogEntitySettingsMenu { + group?: string; + title: string; + components: { + View: React.ComponentType; + }; +} + +export interface CatalogEntityContextMenuNavigate { + /** + * @param pathname The location to navigate to in the main iframe + */ + (pathname: string, forceMainFrame?: boolean): void; + /** + * @param pathname The location to navigate to in the current iframe. Useful for when called within the cluster frame + */ + (pathname: string, forceMainFrame: false): void; +} + +export interface CatalogEntityContextMenuContext { + /** + * Navigate to the specified pathname + */ + navigate: CatalogEntityContextMenuNavigate; + menuItems: CatalogEntityContextMenu[]; +} + +export interface CatalogEntitySettingsContext { + menuItems: CatalogEntityContextMenu[]; +} + +export interface CatalogEntityAddMenuContext { + navigate: (url: string) => void; + menuItems: CatalogEntityAddMenu[]; +} + +export type CatalogEntitySpec = Record; + + +export interface CatalogEntityData< + Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, + Status extends CatalogEntityStatus = CatalogEntityStatus, + Spec extends CatalogEntitySpec = CatalogEntitySpec, +> { + metadata: Metadata; + status: Status; + spec: Spec; +} + +export interface CatalogEntityKindData { + readonly apiVersion: string; + readonly kind: string; +} + +export abstract class CatalogEntity< + Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, + Status extends CatalogEntityStatus = CatalogEntityStatus, + Spec extends CatalogEntitySpec = CatalogEntitySpec, +> implements CatalogEntityKindData { + /** + * The group and version of this class. + */ + public abstract readonly apiVersion: string; + + /** + * A DNS label name of the entity. + */ + public abstract readonly kind: string; + + @observable metadata: Metadata; + @observable status: Status; + @observable spec: Spec; + + constructor({ metadata, status, spec }: CatalogEntityData) { + makeObservable(this); + + if (!metadata || typeof metadata !== "object") { + throw new TypeError("CatalogEntity's metadata must be a defined object"); + } + + if (!status || typeof status !== "object") { + throw new TypeError("CatalogEntity's status must be a defined object"); + } + + if (!spec || typeof spec !== "object") { + throw new TypeError("CatalogEntity's spec must be a defined object"); + } + + this.metadata = metadata; + this.status = status; + this.spec = spec; + } + + /** + * Get the UID of this entity + */ + public getId(): string { + return this.metadata.uid; + } + + /** + * Get the name of this entity + */ + public getName(): string { + return this.metadata.name; + } + + /** + * Get the specified source of this entity, defaulting to `"unknown"` if not + * provided + */ + public getSource(): string { + return this.metadata.source ?? "unknown"; + } + + /** + * Get if this entity is enabled. + */ + public isEnabled(): boolean { + return this.status.enabled ?? true; + } + + public onRun?(context: CatalogEntityActionContext): void | Promise; + public onContextMenuOpen?(context: CatalogEntityContextMenuContext): void | Promise; + public onSettingsOpen?(context: CatalogEntitySettingsContext): void | Promise; +} diff --git a/packages/core/src/common/catalog/catalog-run-event.ts b/packages/core/src/common/catalog/catalog-run-event.ts new file mode 100644 index 0000000000..c1b5eac986 --- /dev/null +++ b/packages/core/src/common/catalog/catalog-run-event.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CatalogEntity } from "../catalog"; + +export class CatalogRunEvent { + #defaultPrevented: boolean; + #target: CatalogEntity; + + get defaultPrevented() { + return this.#defaultPrevented; + } + + get target() { + return this.#target; + } + + constructor({ target }: { target: CatalogEntity }) { + this.#defaultPrevented = false; + this.#target = target; + } + + preventDefault() { + this.#defaultPrevented = true; + } +} diff --git a/packages/core/src/common/catalog/categories/general.injectable.ts b/packages/core/src/common/catalog/categories/general.injectable.ts new file mode 100644 index 0000000000..d2e3ba7a69 --- /dev/null +++ b/packages/core/src/common/catalog/categories/general.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { GeneralCategory } from "../../catalog-entities"; +import { builtInCategoryInjectionToken } from "../category-registry.injectable"; + +const generalCategoryInjectable = getInjectable({ + id: "general-category", + instantiate: () => new GeneralCategory(), + injectionToken: builtInCategoryInjectionToken, +}); + +export default generalCategoryInjectable; diff --git a/packages/core/src/common/catalog/categories/kubernetes-cluster.injectable.ts b/packages/core/src/common/catalog/categories/kubernetes-cluster.injectable.ts new file mode 100644 index 0000000000..6dc3f9be8d --- /dev/null +++ b/packages/core/src/common/catalog/categories/kubernetes-cluster.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { KubernetesClusterCategory } from "../../catalog-entities/kubernetes-cluster"; +import { builtInCategoryInjectionToken } from "../category-registry.injectable"; + +const kubernetesClusterCategoryInjectable = getInjectable({ + id: "kubernetes-cluster-category", + instantiate: () => new KubernetesClusterCategory(), + injectionToken: builtInCategoryInjectionToken, +}); + +export default kubernetesClusterCategoryInjectable; diff --git a/packages/core/src/common/catalog/categories/weblink.injectable.ts b/packages/core/src/common/catalog/categories/weblink.injectable.ts new file mode 100644 index 0000000000..339758efbf --- /dev/null +++ b/packages/core/src/common/catalog/categories/weblink.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { WebLinkCategory } from "../../catalog-entities"; +import { builtInCategoryInjectionToken } from "../category-registry.injectable"; + +const weblinkCategoryInjectable = getInjectable({ + id: "weblink-category", + instantiate: () => new WebLinkCategory(), + injectionToken: builtInCategoryInjectionToken, +}); + +export default weblinkCategoryInjectable; diff --git a/packages/core/src/common/catalog/category-registry.injectable.ts b/packages/core/src/common/catalog/category-registry.injectable.ts new file mode 100644 index 0000000000..a73ed973f6 --- /dev/null +++ b/packages/core/src/common/catalog/category-registry.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { CatalogCategory } from "./catalog-entity"; +import { CatalogCategoryRegistry } from "./category-registry"; + +export const builtInCategoryInjectionToken = getInjectionToken({ + id: "built-in-category-token", +}); + +const catalogCategoryRegistryInjectable = getInjectable({ + id: "catalog-category-registry", + instantiate: (di) => { + const registry = new CatalogCategoryRegistry(); + const categories = di.injectMany(builtInCategoryInjectionToken); + + for (const category of categories) { + registry.add(category); + } + + return registry; + }, +}); + +export default catalogCategoryRegistryInjectable; diff --git a/packages/core/src/common/catalog/category-registry.ts b/packages/core/src/common/catalog/category-registry.ts new file mode 100644 index 0000000000..75c33d8a10 --- /dev/null +++ b/packages/core/src/common/catalog/category-registry.ts @@ -0,0 +1,103 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, computed, observable, makeObservable } from "mobx"; +import { once } from "lodash"; +import { iter, getOrInsertMap, strictSet } from "../utils"; +import type { Disposer } from "../utils"; +import type { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; + +export type CategoryFilter = (category: CatalogCategory) => any; + +export class CatalogCategoryRegistry { + protected readonly categories = observable.set(); + protected readonly groupKinds = new Map>(); + protected readonly filters = observable.set([], { + deep: false, + }); + + constructor() { + makeObservable(this); + } + + @action add(category: CatalogCategory): Disposer { + const byGroup = getOrInsertMap(this.groupKinds, category.spec.group); + + this.categories.add(category); + strictSet(byGroup, category.spec.names.kind, category); + + return () => { + this.categories.delete(category); + byGroup.delete(category.spec.names.kind); + }; + } + + @computed get items() { + return Array.from(this.categories); + } + + @computed get filteredItems() { + return Array.from( + iter.reduce( + this.filters, + iter.filter, + this.items.values(), + ), + ); + } + + + getForGroupKind(group: string, kind: string): T | undefined { + return this.groupKinds.get(group)?.get(kind) as T; + } + + getEntityForData(data: CatalogEntityData & CatalogEntityKindData) { + const category = this.getCategoryForEntity(data); + + if (!category) { + return null; + } + + const splitApiVersion = data.apiVersion.split("/"); + const version = splitApiVersion[1]; + + const specVersion = category.spec.versions.find((v) => v.name === version); + + if (!specVersion) { + return null; + } + + return new specVersion.entityClass(data); + } + + hasCategoryForEntity({ kind, apiVersion }: CatalogEntityData & CatalogEntityKindData): boolean { + const splitApiVersion = apiVersion.split("/"); + const group = splitApiVersion[0]; + + return this.groupKinds.get(group)?.has(kind) ?? false; + } + + getCategoryForEntity(data: CatalogEntityData & CatalogEntityKindData): T | undefined { + const splitApiVersion = data.apiVersion.split("/"); + const group = splitApiVersion[0]; + + return this.getForGroupKind(group, data.kind); + } + + getByName(name: string) { + return this.items.find(category => category.metadata?.name == name); + } + + /** + * Add a new filter to the set of category filters + * @param fn The function that should return a truthy value if that category should be displayed + * @returns A function to remove that filter + */ + addCatalogCategoryFilter(fn: CategoryFilter): Disposer { + this.filters.add(fn); + + return once(() => void this.filters.delete(fn)); + } +} diff --git a/packages/core/src/common/catalog/filtered-categories.injectable.ts b/packages/core/src/common/catalog/filtered-categories.injectable.ts new file mode 100644 index 0000000000..c84d527ff2 --- /dev/null +++ b/packages/core/src/common/catalog/filtered-categories.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +const filteredCategoriesInjectable = getInjectable({ + id: "filtered-categories", + instantiate: (di) => { + const registry = di.inject(catalogCategoryRegistryInjectable); + + return computed(() => [...registry.filteredItems]); + }, +}); + +export default filteredCategoriesInjectable; diff --git a/packages/core/src/common/catalog/has-category-for-entity.injectable.ts b/packages/core/src/common/catalog/has-category-for-entity.injectable.ts new file mode 100644 index 0000000000..cff7d720c1 --- /dev/null +++ b/packages/core/src/common/catalog/has-category-for-entity.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +export type HasCategoryForEntity = (data: CatalogEntityData & CatalogEntityKindData) => boolean; + +const hasCategoryForEntityInjectable = getInjectable({ + id: "has-category-for-entity", + + instantiate: (di): HasCategoryForEntity => { + const registry = di.inject(catalogCategoryRegistryInjectable); + + return (data) => registry.hasCategoryForEntity(data); + }, +}); + +export default hasCategoryForEntityInjectable; diff --git a/packages/core/src/common/catalog/index.ts b/packages/core/src/common/catalog/index.ts new file mode 100644 index 0000000000..4964dada6a --- /dev/null +++ b/packages/core/src/common/catalog/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./category-registry"; +export * from "./catalog-entity"; diff --git a/packages/core/src/common/catalog/visit-entity-context-menu.injectable.ts b/packages/core/src/common/catalog/visit-entity-context-menu.injectable.ts new file mode 100644 index 0000000000..eb1a2abeba --- /dev/null +++ b/packages/core/src/common/catalog/visit-entity-context-menu.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CatalogEntity, CatalogEntityContextMenuContext } from "./catalog-entity"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +export type VisitEntityContextMenu = (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; + +const visitEntityContextMenuInjectable = getInjectable({ + id: "visit-entity-context-menu", + instantiate: (di): VisitEntityContextMenu => { + const categoryRegistry = di.inject(catalogCategoryRegistryInjectable); + + return (entity, context) => { + entity.onContextMenuOpen?.(context); + categoryRegistry.getCategoryForEntity(entity)?.emit("contextMenuOpen", entity, context); + }; + }, +}); + +export default visitEntityContextMenuInjectable; diff --git a/packages/core/src/common/certificate-authorities/inject-system-cas.injectable.ts b/packages/core/src/common/certificate-authorities/inject-system-cas.injectable.ts new file mode 100644 index 0000000000..f75dcd6c70 --- /dev/null +++ b/packages/core/src/common/certificate-authorities/inject-system-cas.injectable.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import { globalAgent } from "https"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +// DST Root CA X3, which was expired on 9.30.2021 +const DSTRootCAX3 = "-----BEGIN CERTIFICATE-----\nMIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/\nMSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT\nDkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow\nPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD\nEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O\nrz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq\nOLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b\nxiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw\n7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD\naeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\nHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG\nSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69\nikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr\nAvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz\nR8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5\nJDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo\nOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ\n-----END CERTIFICATE-----\n"; + +function isCertActive(cert: string) { + const isExpired = typeof cert !== "string" || cert.includes(DSTRootCAX3); + + return !isExpired; +} + +const injectSystemCAsInjectable = getInjectable({ + id: "inject-system-cas", + instantiate: (di) => { + const requestSystemCAs = di.inject(requestSystemCAsInjectionToken); + + return async () => { + const certs = await requestSystemCAs(); + + if (certs.length === 0) { + // Leave the global option alone + return; + } + + const cas = (() => { + if (Array.isArray(globalAgent.options.ca)) { + return globalAgent.options.ca; + } + + if (globalAgent.options.ca) { + return [globalAgent.options.ca]; + } + + return []; + })(); + + for (const cert of certs) { + if (!isCertActive(cert)) { + continue; + } + + if (!cas.includes(cert)) { + cas.push(cert); + } + } + + globalAgent.options.ca = cas; + }; + }, +}); + +export default injectSystemCAsInjectable; + diff --git a/packages/core/src/common/certificate-authorities/request-system-cas-token.ts b/packages/core/src/common/certificate-authorities/request-system-cas-token.ts new file mode 100644 index 0000000000..c69b0bd8b0 --- /dev/null +++ b/packages/core/src/common/certificate-authorities/request-system-cas-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const requestSystemCAsInjectionToken = getInjectionToken<() => Promise>({ + id: "request-system-cas-token", +}); diff --git a/packages/core/src/common/certificate-authorities/request-system-cas.injectable.darwin.ts b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.darwin.ts new file mode 100644 index 0000000000..c471c954e4 --- /dev/null +++ b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.darwin.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import execFileInjectable from "../fs/exec-file.injectable"; +import loggerInjectable from "../logger.injectable"; +import type { AsyncResult } from "../utils/async-result"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet#other_assertions +const certSplitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: (di) => { + const execFile = di.inject(execFileInjectable); + const logger = di.inject(loggerInjectable); + + const execSecurity = async (...args: string[]): Promise> => { + const result = await execFile("/usr/bin/security", args); + + if (!result.callWasSuccessful) { + return { + callWasSuccessful: false, + error: result.error.stderr || result.error.message, + }; + } + + return { + callWasSuccessful: true, + response: result.response.split(certSplitPattern), + }; + }; + + return async () => { + const [trustedResult, rootCAResult] = await Promise.all([ + execSecurity("find-certificate", "-a", "-p"), + execSecurity("find-certificate", "-a", "-p", "/System/Library/Keychains/SystemRootCertificates.keychain"), + ]); + + if (!trustedResult.callWasSuccessful) { + logger.warn(`[INJECT-CAS]: Error retreiving trusted CAs: ${trustedResult.error}`); + } else if (!rootCAResult.callWasSuccessful) { + logger.warn(`[INJECT-CAS]: Error retreiving root CAs: ${rootCAResult.error}`); + } else { + return [...new Set([...trustedResult.response, ...rootCAResult.response])]; + } + + return []; + }; + }, + causesSideEffects: true, + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/packages/core/src/common/certificate-authorities/request-system-cas.injectable.linux.ts b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.linux.ts new file mode 100644 index 0000000000..1d7bf10350 --- /dev/null +++ b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.linux.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: () => async () => [], + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/packages/core/src/common/certificate-authorities/request-system-cas.injectable.testing-env.ts b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.testing-env.ts new file mode 100644 index 0000000000..1d7bf10350 --- /dev/null +++ b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.testing-env.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: () => async () => [], + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/packages/core/src/common/certificate-authorities/request-system-cas.injectable.win32.ts b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.win32.ts new file mode 100644 index 0000000000..4940aa2a7b --- /dev/null +++ b/packages/core/src/common/certificate-authorities/request-system-cas.injectable.win32.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import execFileInjectable from "../fs/exec-file.injectable"; +import loggerInjectable from "../logger.injectable"; +import { requestSystemCAsInjectionToken } from "./request-system-cas-token"; + +const pemEncoding = (hexEncodedCert: String) => { + const certData = Buffer.from(hexEncodedCert, "hex").toString("base64"); + const lines = ["-----BEGIN CERTIFICATE-----"]; + + for (let i = 0; i < certData.length; i += 64) { + lines.push(certData.substring(i, i + 64)); + } + + lines.push("-----END CERTIFICATE-----", ""); + + return lines.join("\r\n"); +}; + +const requestSystemCAsInjectable = getInjectable({ + id: "request-system-cas", + instantiate: (di) => { + const wincaRootsExePath: string = __non_webpack_require__.resolve("win-ca/lib/roots.exe"); + const execFile = di.inject(execFileInjectable); + const logger = di.inject(loggerInjectable); + + return async () => { + /** + * This needs to be done manually because for some reason calling the api from "win-ca" + * directly fails to load "child_process" correctly on renderer + */ + const result = await execFile(wincaRootsExePath, { + maxBuffer: 128 * 1024 * 1024, // 128 MiB + }); + + if (!result.callWasSuccessful) { + logger.warn(`[INJECT-CAS]: Error retreiving CAs`, result.error); + + return []; + } + + return result + .response + .split("\r\n") + .filter(Boolean) + .map(pemEncoding); + }; + }, + causesSideEffects: true, + injectionToken: requestSystemCAsInjectionToken, +}); + +export default requestSystemCAsInjectable; diff --git a/packages/core/src/common/certificate/lens-proxy-certificate-channel.ts b/packages/core/src/common/certificate/lens-proxy-certificate-channel.ts new file mode 100644 index 0000000000..7d9652ce5c --- /dev/null +++ b/packages/core/src/common/certificate/lens-proxy-certificate-channel.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { SelfSignedCert } from "selfsigned"; +import { getRequestChannel } from "../utils/channel/get-request-channel"; + +export const lensProxyCertificateChannel = getRequestChannel("request-lens-proxy-certificate"); diff --git a/packages/core/src/common/certificate/lens-proxy-certificate.global-override-for-injectable.ts b/packages/core/src/common/certificate/lens-proxy-certificate.global-override-for-injectable.ts new file mode 100644 index 0000000000..d547516062 --- /dev/null +++ b/packages/core/src/common/certificate/lens-proxy-certificate.global-override-for-injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import lensProxyCertificateInjectable from "./lens-proxy-certificate.injectable"; + +export default getGlobalOverride(lensProxyCertificateInjectable, () => { + return { + get: () => ({ + public: "", + private: "", + cert: "", + }), + set: () => {}, + }; +}); + diff --git a/packages/core/src/common/certificate/lens-proxy-certificate.injectable.ts b/packages/core/src/common/certificate/lens-proxy-certificate.injectable.ts new file mode 100644 index 0000000000..b5e00e0094 --- /dev/null +++ b/packages/core/src/common/certificate/lens-proxy-certificate.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { SelfSignedCert } from "selfsigned"; + +const lensProxyCertificateInjectable = getInjectable({ + id: "lens-proxy-certificate", + instantiate: () => { + let certState: SelfSignedCert; + const cert = { + get: () => { + if (!certState) { + throw "certificate has not been set"; + } + + return certState; + }, + set: (certificate: SelfSignedCert) => { + if (certState) { + throw "certificate has already been set"; + } + + certState = certificate; + }, + }; + + return cert; + }, +}); + +export default lensProxyCertificateInjectable; diff --git a/packages/core/src/common/cluster-frames.injectable.ts b/packages/core/src/common/cluster-frames.injectable.ts new file mode 100644 index 0000000000..23897012a0 --- /dev/null +++ b/packages/core/src/common/cluster-frames.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { clusterFrameMap } from "./cluster-frames"; + +const clusterFramesInjectable = getInjectable({ + id: "cluster-frames", + instantiate: () => clusterFrameMap, + causesSideEffects: true, +}); + +export default clusterFramesInjectable; diff --git a/packages/core/src/common/cluster-frames.ts b/packages/core/src/common/cluster-frames.ts new file mode 100644 index 0000000000..6a4757e68a --- /dev/null +++ b/packages/core/src/common/cluster-frames.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { observable } from "mobx"; + +export interface ClusterFrameInfo { + frameId: number; + processId: number; +} + +export const clusterFrameMap = observable.map(); diff --git a/packages/core/src/common/cluster-store/allowed-resources-injection-token.ts b/packages/core/src/common/cluster-store/allowed-resources-injection-token.ts new file mode 100644 index 0000000000..5b71038d04 --- /dev/null +++ b/packages/core/src/common/cluster-store/allowed-resources-injection-token.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { KubeApiResourceDescriptor } from "../rbac"; + +export const shouldShowResourceInjectionToken = getInjectionToken, KubeApiResourceDescriptor>({ + id: "should-show-resource", +}); diff --git a/packages/core/src/common/cluster-store/cluster-store.injectable.ts b/packages/core/src/common/cluster-store/cluster-store.injectable.ts new file mode 100644 index 0000000000..9712e3fdb0 --- /dev/null +++ b/packages/core/src/common/cluster-store/cluster-store.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { ClusterStore } from "./cluster-store"; +import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; +import readClusterConfigSyncInjectable from "./read-cluster-config.injectable"; +import emitAppEventInjectable from "../app-event-bus/emit-event.injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; +import loggerInjectable from "../logger.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import storeMigrationsInjectable from "../base-store/migrations.injectable"; +import { clusterStoreMigrationInjectionToken } from "./migration-token"; +import { baseStoreIpcChannelPrefixesInjectionToken } from "../base-store/channel-prefix"; +import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../base-store/disable-sync"; +import { persistStateToConfigInjectionToken } from "../base-store/save-to-file"; +import getBasenameOfPathInjectable from "../path/get-basename.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../utils/channel/enlist-message-channel-listener-injection-token"; + +const clusterStoreInjectable = getInjectable({ + id: "cluster-store", + + instantiate: (di) => new ClusterStore({ + createCluster: di.inject(createClusterInjectionToken), + readClusterConfigSync: di.inject(readClusterConfigSyncInjectable), + emitAppEvent: di.inject(emitAppEventInjectable), + directoryForUserData: di.inject(directoryForUserDataInjectable), + getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable), + logger: di.inject(loggerInjectable), + storeMigrationVersion: di.inject(storeMigrationVersionInjectable), + migrations: di.inject(storeMigrationsInjectable, clusterStoreMigrationInjectionToken), + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken), + persistStateToConfig: di.inject(persistStateToConfigInjectionToken), + enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken), + shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), + }), +}); + +export default clusterStoreInjectable; diff --git a/packages/core/src/common/cluster-store/cluster-store.ts b/packages/core/src/common/cluster-store/cluster-store.ts new file mode 100644 index 0000000000..20929cf77e --- /dev/null +++ b/packages/core/src/common/cluster-store/cluster-store.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +import { action, comparer, computed, makeObservable, observable } from "mobx"; +import type { BaseStoreDependencies } from "../base-store/base-store"; +import { BaseStore } from "../base-store/base-store"; +import { Cluster } from "../cluster/cluster"; +import { toJS } from "../utils"; +import type { ClusterModel, ClusterId } from "../cluster-types"; +import type { CreateCluster } from "../cluster/create-cluster-injection-token"; +import type { ReadClusterConfigSync } from "./read-cluster-config.injectable"; +import type { EmitAppEvent } from "../app-event-bus/emit-event.injectable"; + +export interface ClusterStoreModel { + clusters?: ClusterModel[]; +} + +interface Dependencies extends BaseStoreDependencies { + createCluster: CreateCluster; + readClusterConfigSync: ReadClusterConfigSync; + emitAppEvent: EmitAppEvent; +} + +export class ClusterStore extends BaseStore { + readonly clusters = observable.map(); + + constructor(protected readonly dependencies: Dependencies) { + super(dependencies, { + configName: "lens-cluster-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, + }); + + makeObservable(this); + } + + @computed get clustersList(): Cluster[] { + return Array.from(this.clusters.values()); + } + + @computed get connectedClustersList(): Cluster[] { + return this.clustersList.filter((c) => !c.disconnected); + } + + hasClusters() { + return this.clusters.size > 0; + } + + getById(id: ClusterId | undefined): Cluster | undefined { + if (id) { + return this.clusters.get(id); + } + + return undefined; + } + + addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { + this.dependencies.emitAppEvent({ name: "cluster", action: "add" }); + + const cluster = clusterOrModel instanceof Cluster + ? clusterOrModel + : this.dependencies.createCluster( + clusterOrModel, + this.dependencies.readClusterConfigSync(clusterOrModel), + ); + + this.clusters.set(cluster.id, cluster); + + return cluster; + } + + @action + protected fromStore({ clusters = [] }: ClusterStoreModel = {}) { + const currentClusters = new Map(this.clusters); + const newClusters = new Map(); + + // update new clusters + for (const clusterModel of clusters) { + try { + let cluster = currentClusters.get(clusterModel.id); + + if (cluster) { + cluster.updateModel(clusterModel); + } else { + cluster = this.dependencies.createCluster( + clusterModel, + this.dependencies.readClusterConfigSync(clusterModel), + ); + } + newClusters.set(clusterModel.id, cluster); + } catch (error) { + this.dependencies.logger.warn(`[CLUSTER-STORE]: Failed to update/create a cluster: ${error}`); + } + } + + this.clusters.replace(newClusters); + } + + toJSON(): ClusterStoreModel { + return toJS({ + clusters: this.clustersList.map(cluster => cluster.toJSON()), + }); + } +} diff --git a/packages/core/src/common/cluster-store/get-by-id.injectable.ts b/packages/core/src/common/cluster-store/get-by-id.injectable.ts new file mode 100644 index 0000000000..534bdb5e76 --- /dev/null +++ b/packages/core/src/common/cluster-store/get-by-id.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ClusterId } from "../cluster-types"; +import type { Cluster } from "../cluster/cluster"; +import clusterStoreInjectable from "./cluster-store.injectable"; + +export type GetClusterById = (id: ClusterId) => Cluster | undefined; + +const getClusterByIdInjectable = getInjectable({ + id: "get-cluster-by-id", + instantiate: (di): GetClusterById => { + const store = di.inject(clusterStoreInjectable); + + return (id) => store.getById(id); + }, +}); + +export default getClusterByIdInjectable; diff --git a/packages/core/src/common/cluster-store/migration-token.ts b/packages/core/src/common/cluster-store/migration-token.ts new file mode 100644 index 0000000000..86489509a2 --- /dev/null +++ b/packages/core/src/common/cluster-store/migration-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MigrationDeclaration } from "../base-store/migrations.injectable"; + +export const clusterStoreMigrationInjectionToken = getInjectionToken({ + id: "cluster-store-migration", +}); diff --git a/packages/core/src/common/cluster-store/read-cluster-config.injectable.ts b/packages/core/src/common/cluster-store/read-cluster-config.injectable.ts new file mode 100644 index 0000000000..9fea013b16 --- /dev/null +++ b/packages/core/src/common/cluster-store/read-cluster-config.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ClusterConfigData, ClusterModel } from "../cluster-types"; +import readFileSyncInjectable from "../fs/read-file-sync.injectable"; +import { loadConfigFromString, validateKubeConfig } from "../kube-helpers"; + +export type ReadClusterConfigSync = (model: ClusterModel) => ClusterConfigData; + +const readClusterConfigSyncInjectable = getInjectable({ + id: "read-cluster-config-sync", + instantiate: (di): ReadClusterConfigSync => { + const readFileSync = di.inject(readFileSyncInjectable); + + return ({ kubeConfigPath, contextName }) => { + const kubeConfigData = readFileSync(kubeConfigPath); + const { config } = loadConfigFromString(kubeConfigData); + const result = validateKubeConfig(config, contextName); + + if (result.error) { + throw result.error; + } + + return { clusterServerUrl: result.cluster.server }; + }; + }, +}); + +export default readClusterConfigSyncInjectable; diff --git a/packages/core/src/common/cluster-types.ts b/packages/core/src/common/cluster-types.ts new file mode 100644 index 0000000000..0cd447f0e2 --- /dev/null +++ b/packages/core/src/common/cluster-types.ts @@ -0,0 +1,211 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import Joi from "joi"; + +/** + * JSON serializable metadata type + */ +export type ClusterMetadata = Record; + +/** + * Metadata for cluster's prometheus settings + */ +export interface ClusterPrometheusMetadata { + success?: boolean; + provider?: string; + autoDetected?: boolean; +} + +/** + * A ClusterId is an opaque string + */ +export type ClusterId = string; + +/** + * The fields that are used for updating a cluster instance + */ +export type UpdateClusterModel = Omit; + +/** + * A type validator for `UpdateClusterModel` so that only expected types are present + */ +export const updateClusterModelChecker = Joi.object({ + kubeConfigPath: Joi.string() + .required() + .min(1), + contextName: Joi.string() + .required() + .min(1), + workspace: Joi.string() + .optional(), + workspaces: Joi.array() + .items(Joi.string()), + preferences: Joi.object(), + metadata: Joi.object(), + accessibleNamespaces: Joi.array() + .items(Joi.string()), + labels: Joi.object().pattern(Joi.string(), Joi.string()), +}); + +/** + * A type validator for just the `id` fields of `ClusterModel`. The rest is + * covered by `updateClusterModelChecker` + */ +export const clusterModelIdChecker = Joi.object>({ + id: Joi.string() + .required() + .min(1), +}); + +/** + * The model for passing cluster data around, including to disk + */ +export interface ClusterModel { + /** Unique id for a cluster */ + id: ClusterId; + + /** Path to cluster kubeconfig */ + kubeConfigPath: string; + + /** + * Workspace id + * + * @deprecated + */ + workspace?: string; + + /** + * @deprecated this is used only for hotbar migrations from 4.2.X + */ + workspaces?: string[]; + + /** User context in kubeconfig */ + contextName: string; + + /** Preferences */ + preferences?: ClusterPreferences; + + /** Metadata */ + metadata?: ClusterMetadata; + + /** List of accessible namespaces */ + accessibleNamespaces?: string[]; + + /** + * Labels for the catalog entity + */ + labels?: Record; +} + +/** + * This data is retreived from the kubeconfig file before calling the cluster constructor. + * + * That is done to remove the external dependency on the construction of Cluster instances. + */ +export interface ClusterConfigData { + clusterServerUrl: string; +} + +/** + * The complete set of cluster settings or preferences + */ +export interface ClusterPreferences extends ClusterPrometheusPreferences { + terminalCWD?: string; + clusterName?: string; + iconOrder?: number; + /** + * The src for the cluster. If set to `null` that means that it was + * cleared by preferences. + */ + icon?: string | null; + httpsProxy?: string; + hiddenMetrics?: string[]; + nodeShellImage?: string; + imagePullSecret?: string; + defaultNamespace?: string; +} + +/** + * A cluster's prometheus settings (a subset of cluster settings) + */ +export interface ClusterPrometheusPreferences { + prometheus?: { + namespace: string; + service: string; + port: number; + prefix: string; + }; + prometheusProvider?: { + type: string; + }; +} + +/** + * The options for the status of connection attempts to a cluster + */ +export enum ClusterStatus { + AccessGranted = 2, + AccessDenied = 1, + Offline = 0, +} + +/** + * The message format for the "cluster::connection-update" channels + */ +export interface KubeAuthUpdate { + message: string; + isError: boolean; +} + +/** + * The OpenLens known static metadata keys + */ +export enum ClusterMetadataKey { + VERSION = "version", + CLUSTER_ID = "id", + DISTRIBUTION = "distribution", + NODES_COUNT = "nodes", + LAST_SEEN = "lastSeen", + PROMETHEUS = "prometheus", +} + +/** + * A shorthand enum for resource types that have metrics attached to them via OpenLens metrics stack + */ +export enum ClusterMetricsResourceType { + Cluster = "Cluster", + Node = "Node", + Pod = "Pod", + Deployment = "Deployment", + StatefulSet = "StatefulSet", + Container = "Container", + Ingress = "Ingress", + VolumeClaim = "VolumeClaim", + ReplicaSet = "ReplicaSet", + DaemonSet = "DaemonSet", + Job = "Job", + Namespace = "Namespace", +} + +/** + * The default node shell image + */ +export const initialNodeShellImage = "docker.io/alpine:3.13"; + +/** + * The data representing a cluster's state, for passing between main and renderer + */ +export interface ClusterState { + apiUrl: string; + online: boolean; + disconnected: boolean; + accessible: boolean; + ready: boolean; + isAdmin: boolean; + allowedNamespaces: string[]; + allowedResources: string[]; + isGlobalWatchEnabled: boolean; +} diff --git a/packages/core/src/common/cluster/authorization-review.injectable.ts b/packages/core/src/common/cluster/authorization-review.injectable.ts new file mode 100644 index 0000000000..4c9b83330d --- /dev/null +++ b/packages/core/src/common/cluster/authorization-review.injectable.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; +import { AuthorizationV1Api } from "@kubernetes/client-node"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { Logger } from "../logger"; +import loggerInjectable from "../logger.injectable"; + +/** + * Requests the permissions for actions on the kube cluster + * @param resourceAttributes The descriptor of the action that is desired to be known if it is allowed + * @returns `true` if the actions described are allowed + */ +export type CanI = (resourceAttributes: V1ResourceAttributes) => Promise; + +/** + * @param proxyConfig This config's `currentContext` field must be set, and will be used as the target cluster + */ +export type AuthorizationReview = (proxyConfig: KubeConfig) => CanI; + +interface Dependencies { + logger: Logger; +} + +const authorizationReview = ({ logger }: Dependencies): AuthorizationReview => { + return (proxyConfig) => { + const api = proxyConfig.makeApiClient(AuthorizationV1Api); + + return async (resourceAttributes: V1ResourceAttributes): Promise => { + try { + const { body } = await api.createSelfSubjectAccessReview({ + apiVersion: "authorization.k8s.io/v1", + kind: "SelfSubjectAccessReview", + spec: { resourceAttributes }, + }); + + return body.status?.allowed ?? false; + } catch (error) { + logger.error(`[AUTHORIZATION-REVIEW]: failed to create access review: ${error}`, { resourceAttributes }); + + return false; + } + }; + }; +}; + +const authorizationReviewInjectable = getInjectable({ + id: "authorization-review", + instantiate: (di) => { + const logger = di.inject(loggerInjectable); + + return authorizationReview({ logger }); + }, +}); + +export default authorizationReviewInjectable; diff --git a/packages/core/src/common/cluster/cluster.ts b/packages/core/src/common/cluster/cluster.ts new file mode 100644 index 0000000000..c8e7e69e90 --- /dev/null +++ b/packages/core/src/common/cluster/cluster.ts @@ -0,0 +1,704 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; +import type { ClusterContextHandler } from "../../main/context-handler/context-handler"; +import type { KubeConfig } from "@kubernetes/client-node"; +import { HttpError } from "@kubernetes/client-node"; +import type { Kubectl } from "../../main/kubectl/kubectl"; +import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig-manager"; +import type { KubeApiResource, KubeApiResourceDescriptor } from "../rbac"; +import { formatKubeApiResource } from "../rbac"; +import type { VersionDetector } from "../../main/cluster-detectors/version-detector"; +import type { DetectorRegistry } from "../../main/cluster-detectors/detector-registry"; +import plimit from "p-limit"; +import type { ClusterState, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate, ClusterConfigData } from "../cluster-types"; +import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus, clusterModelIdChecker, updateClusterModelChecker } from "../cluster-types"; +import { disposer, isDefined, isRequestError, toJS } from "../utils"; +import type { Response } from "request"; +import { clusterListNamespaceForbiddenChannel } from "../ipc/cluster"; +import type { CanI } from "./authorization-review.injectable"; +import type { ListNamespaces } from "./list-namespaces.injectable"; +import assert from "assert"; +import type { Logger } from "../logger"; +import type { BroadcastMessage } from "../ipc/broadcast-message.injectable"; +import type { LoadConfigfromFile } from "../kube-helpers/load-config-from-file.injectable"; +import type { CanListResource, RequestNamespaceListPermissions, RequestNamespaceListPermissionsFor } from "./request-namespace-list-permissions.injectable"; +import type { RequestApiResources } from "../../main/cluster/request-api-resources.injectable"; + +export interface ClusterDependencies { + readonly directoryForKubeConfigs: string; + readonly logger: Logger; + readonly detectorRegistry: DetectorRegistry; + createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; + createContextHandler: (cluster: Cluster) => ClusterContextHandler; + createKubectl: (clusterVersion: string) => Kubectl; + createAuthorizationReview: (config: KubeConfig) => CanI; + requestApiResources: RequestApiResources; + requestNamespaceListPermissionsFor: RequestNamespaceListPermissionsFor; + createListNamespaces: (config: KubeConfig) => ListNamespaces; + createVersionDetector: (cluster: Cluster) => VersionDetector; + broadcastMessage: BroadcastMessage; + loadConfigfromFile: LoadConfigfromFile; +} + +/** + * Cluster + * + * @beta + */ +export class Cluster implements ClusterModel { + /** Unique id for a cluster */ + public readonly id: ClusterId; + private kubeCtl: Kubectl | undefined; + /** + * Context handler + * + * @internal + */ + protected readonly _contextHandler: ClusterContextHandler | undefined; + protected readonly _proxyKubeconfigManager: KubeconfigManager | undefined; + protected readonly eventsDisposer = disposer(); + protected activated = false; + + public get contextHandler() { + // TODO: remove these once main/renderer are seperate classes + assert(this._contextHandler, "contextHandler is only defined in the main environment"); + + return this._contextHandler; + } + + protected get proxyKubeconfigManager() { + // TODO: remove these once main/renderer are seperate classes + assert(this._proxyKubeconfigManager, "proxyKubeconfigManager is only defined in the main environment"); + + return this._proxyKubeconfigManager; + } + + get whenReady() { + return when(() => this.ready); + } + + /** + * Kubeconfig context name + * + * @observable + */ + @observable contextName!: string; + /** + * Path to kubeconfig + * + * @observable + */ + @observable kubeConfigPath!: string; + /** + * @deprecated + */ + @observable workspace?: string; + /** + * @deprecated + */ + @observable workspaces?: string[]; + /** + * Kubernetes API server URL + * + * @observable + */ + @observable apiUrl: string; // cluster server url + /** + * Is cluster online + * + * @observable + */ + @observable online = false; // describes if we can detect that cluster is online + /** + * Can user access cluster resources + * + * @observable + */ + @observable accessible = false; // if user is able to access cluster resources + /** + * Is cluster instance in usable state + * + * @observable + */ + @observable ready = false; // cluster is in usable state + /** + * Is cluster currently reconnecting + * + * @observable + */ + @observable reconnecting = false; + /** + * Is cluster disconnected. False if user has selected to connect. + * + * @observable + */ + @observable disconnected = true; + /** + * Does user have admin like access + * + * @observable + */ + @observable isAdmin = false; + + /** + * Global watch-api accessibility , e.g. "/api/v1/services?watch=1" + * + * @observable + */ + @observable isGlobalWatchEnabled = false; + /** + * Preferences + * + * @observable + */ + @observable preferences: ClusterPreferences = {}; + /** + * Metadata + * + * @observable + */ + @observable metadata: ClusterMetadata = {}; + + /** + * List of allowed namespaces verified via K8S::SelfSubjectAccessReview api + */ + readonly allowedNamespaces = observable.array(); + + /** + * List of accessible namespaces provided by user in the Cluster Settings + */ + readonly accessibleNamespaces = observable.array(); + + private readonly knownResources = observable.array(); + + // The formatting of this is `group.name` or `name` (if in core) + private readonly allowedResources = observable.set(); + + /** + * Labels for the catalog entity + */ + @observable labels: Record = {}; + + /** + * Is cluster available + * + * @computed + */ + @computed get available() { + return this.accessible && !this.disconnected; + } + + /** + * Cluster name + * + * @computed + */ + @computed get name() { + return this.preferences.clusterName || this.contextName; + } + + /** + * The detected kubernetes distribution + */ + @computed get distribution(): string { + return this.metadata[ClusterMetadataKey.DISTRIBUTION]?.toString() || "unknown"; + } + + /** + * The detected kubernetes version + */ + @computed get version(): string { + return this.metadata[ClusterMetadataKey.VERSION]?.toString() || "unknown"; + } + + /** + * Prometheus preferences + * + * @computed + * @internal + */ + @computed get prometheusPreferences(): ClusterPrometheusPreferences { + const { prometheus, prometheusProvider } = this.preferences; + + return toJS({ prometheus, prometheusProvider }); + } + + /** + * defaultNamespace preference + * + * @computed + * @internal + */ + @computed get defaultNamespace(): string | undefined { + return this.preferences.defaultNamespace; + } + + constructor(private readonly dependencies: ClusterDependencies, { id, ...model }: ClusterModel, configData: ClusterConfigData) { + makeObservable(this); + + const { error } = clusterModelIdChecker.validate({ id }); + + if (error) { + throw error; + } + + this.id = id; + this.updateModel(model); + this.apiUrl = configData.clusterServerUrl; + + // for the time being, until renderer gets its own cluster type + this._contextHandler = this.dependencies.createContextHandler(this); + this._proxyKubeconfigManager = this.dependencies.createKubeconfigManager(this); + this.dependencies.logger.debug(`[CLUSTER]: Cluster init success`, { + id: this.id, + context: this.contextName, + apiUrl: this.apiUrl, + }); + } + + /** + * Update cluster data model + * + * @param model + */ + @action updateModel(model: UpdateClusterModel) { + // Note: do not assign ID as that should never be updated + + const { error } = updateClusterModelChecker.validate(model, { allowUnknown: true }); + + if (error) { + throw error; + } + + this.kubeConfigPath = model.kubeConfigPath; + this.contextName = model.contextName; + + if (model.workspace) { + this.workspace = model.workspace; + } + + if (model.workspaces) { + this.workspaces = model.workspaces; + } + + if (model.preferences) { + this.preferences = model.preferences; + } + + if (model.metadata) { + this.metadata = model.metadata; + } + + if (model.accessibleNamespaces) { + this.accessibleNamespaces.replace(model.accessibleNamespaces); + } + + if (model.labels) { + this.labels = model.labels; + } + } + + /** + * @internal + */ + protected bindEvents() { + this.dependencies.logger.info(`[CLUSTER]: bind events`, this.getMeta()); + const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s + const refreshMetadataTimer = setInterval(() => this.available && this.refreshAccessibilityAndMetadata(), 900000); // every 15 minutes + + this.eventsDisposer.push( + reaction( + () => this.prometheusPreferences, + prefs => this.contextHandler.setupPrometheus(prefs), + { equals: comparer.structural }, + ), + () => clearInterval(refreshTimer), + () => clearInterval(refreshMetadataTimer), + reaction(() => this.defaultNamespace, () => this.recreateProxyKubeconfig()), + ); + } + + /** + * @internal + */ + protected async recreateProxyKubeconfig() { + this.dependencies.logger.info("[CLUSTER]: Recreating proxy kubeconfig"); + + try { + await this.proxyKubeconfigManager.clear(); + await this.getProxyKubeconfig(); + } catch (error) { + this.dependencies.logger.error(`[CLUSTER]: failed to recreate proxy kubeconfig`, error); + } + } + + /** + * @param force force activation + * @internal + */ + @action + async activate(force = false) { + if (this.activated && !force) { + return; + } + + this.dependencies.logger.info(`[CLUSTER]: activate`, this.getMeta()); + + if (!this.eventsDisposer.length) { + this.bindEvents(); + } + + if (this.disconnected || !this.accessible) { + try { + this.broadcastConnectUpdate("Starting connection ..."); + await this.reconnect(); + } catch (error) { + this.broadcastConnectUpdate(`Failed to start connection: ${error}`, true); + + return; + } + } + + try { + this.broadcastConnectUpdate("Refreshing connection status ..."); + await this.refreshConnectionStatus(); + } catch (error) { + this.broadcastConnectUpdate(`Failed to connection status: ${error}`, true); + + return; + } + + if (this.accessible) { + try { + this.broadcastConnectUpdate("Refreshing cluster accessibility ..."); + await this.refreshAccessibility(); + } catch (error) { + this.broadcastConnectUpdate(`Failed to refresh accessibility: ${error}`, true); + + return; + } + + // download kubectl in background, so it's not blocking dashboard + this.ensureKubectl() + .catch(error => this.dependencies.logger.warn(`[CLUSTER]: failed to download kubectl for clusterId=${this.id}`, error)); + this.broadcastConnectUpdate("Connected, waiting for view to load ..."); + } + + this.activated = true; + } + + /** + * @internal + */ + async ensureKubectl() { + this.kubeCtl ??= this.dependencies.createKubectl(this.version); + + await this.kubeCtl.ensureKubectl(); + + return this.kubeCtl; + } + + /** + * @internal + */ + @action + async reconnect() { + this.dependencies.logger.info(`[CLUSTER]: reconnect`, this.getMeta()); + await this.contextHandler?.restartServer(); + this.disconnected = false; + } + + /** + * @internal + */ + @action disconnect(): void { + if (this.disconnected) { + return void this.dependencies.logger.debug("[CLUSTER]: already disconnected", { id: this.id }); + } + + this.dependencies.logger.info(`[CLUSTER]: disconnecting`, { id: this.id }); + this.eventsDisposer(); + this.contextHandler?.stopServer(); + this.disconnected = true; + this.online = false; + this.accessible = false; + this.ready = false; + this.activated = false; + this.allowedNamespaces.clear(); + this.dependencies.logger.info(`[CLUSTER]: disconnected`, { id: this.id }); + } + + /** + * @internal + */ + @action + async refresh() { + this.dependencies.logger.info(`[CLUSTER]: refresh`, this.getMeta()); + await this.refreshConnectionStatus(); + } + + /** + * @internal + */ + @action + async refreshAccessibilityAndMetadata() { + await this.refreshAccessibility(); + await this.refreshMetadata(); + } + + /** + * @internal + */ + async refreshMetadata() { + this.dependencies.logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); + const metadata = await this.dependencies.detectorRegistry.detectForCluster(this); + const existingMetadata = this.metadata; + + this.metadata = Object.assign(existingMetadata, metadata); + } + + /** + * @internal + */ + private async refreshAccessibility(): Promise { + this.dependencies.logger.info(`[CLUSTER]: refreshAccessibility`, this.getMeta()); + const proxyConfig = await this.getProxyKubeconfig(); + const canI = this.dependencies.createAuthorizationReview(proxyConfig); + const requestNamespaceListPermissions = this.dependencies.requestNamespaceListPermissionsFor(proxyConfig); + + this.isAdmin = await canI({ + namespace: "kube-system", + resource: "*", + verb: "create", + }); + this.isGlobalWatchEnabled = await canI({ + verb: "watch", + resource: "*", + }); + this.allowedNamespaces.replace(await this.requestAllowedNamespaces(proxyConfig)); + this.knownResources.replace(await this.dependencies.requestApiResources(this)); + this.allowedResources.replace(await this.getAllowedResources(requestNamespaceListPermissions)); + this.ready = true; + } + + /** + * @internal + */ + @action + async refreshConnectionStatus() { + const connectionStatus = await this.getConnectionStatus(); + + this.online = connectionStatus > ClusterStatus.Offline; + this.accessible = connectionStatus == ClusterStatus.AccessGranted; + } + + async getKubeconfig(): Promise { + const { config } = await this.dependencies.loadConfigfromFile(this.kubeConfigPath); + + return config; + } + + /** + * @internal + */ + async getProxyKubeconfig(): Promise { + const proxyKCPath = await this.getProxyKubeconfigPath(); + const { config } = await this.dependencies.loadConfigfromFile(proxyKCPath); + + return config; + } + + /** + * @internal + */ + async getProxyKubeconfigPath(): Promise { + return this.proxyKubeconfigManager.getPath(); + } + + protected async getConnectionStatus(): Promise { + try { + const versionDetector = this.dependencies.createVersionDetector(this); + const versionData = await versionDetector.detect(); + + this.metadata.version = versionData.value; + + return ClusterStatus.AccessGranted; + } catch (error) { + this.dependencies.logger.error(`[CLUSTER]: Failed to connect to "${this.contextName}": ${error}`); + + if (isRequestError(error)) { + if (error.statusCode) { + if (error.statusCode >= 400 && error.statusCode < 500) { + this.broadcastConnectUpdate("Invalid credentials", true); + + return ClusterStatus.AccessDenied; + } + + const message = String(error.error || error.message) || String(error); + + this.broadcastConnectUpdate(message, true); + + return ClusterStatus.Offline; + } + + if (error.failed === true) { + if (error.timedOut === true) { + this.broadcastConnectUpdate("Connection timed out", true); + + return ClusterStatus.Offline; + } + + this.broadcastConnectUpdate("Failed to fetch credentials", true); + + return ClusterStatus.AccessDenied; + } + + const message = String(error.error || error.message) || String(error); + + this.broadcastConnectUpdate(message, true); + } else { + this.broadcastConnectUpdate("Unknown error has occurred", true); + } + + return ClusterStatus.Offline; + } + } + + toJSON(): ClusterModel { + return toJS({ + id: this.id, + contextName: this.contextName, + kubeConfigPath: this.kubeConfigPath, + workspace: this.workspace, + workspaces: this.workspaces, + preferences: this.preferences, + metadata: this.metadata, + accessibleNamespaces: this.accessibleNamespaces, + labels: this.labels, + }); + } + + /** + * Serializable cluster-state used for sync btw main <-> renderer + */ + getState(): ClusterState { + return toJS({ + apiUrl: this.apiUrl, + online: this.online, + ready: this.ready, + disconnected: this.disconnected, + accessible: this.accessible, + isAdmin: this.isAdmin, + allowedNamespaces: this.allowedNamespaces, + allowedResources: [...this.allowedResources], + isGlobalWatchEnabled: this.isGlobalWatchEnabled, + }); + } + + /** + * @internal + * @param state cluster state + */ + @action setState(state: ClusterState) { + this.accessible = state.accessible; + this.allowedNamespaces.replace(state.allowedNamespaces); + this.allowedResources.replace(state.allowedResources); + this.apiUrl = state.apiUrl; + this.disconnected = state.disconnected; + this.isAdmin = state.isAdmin; + this.isGlobalWatchEnabled = state.isGlobalWatchEnabled; + this.online = state.online; + this.ready = state.ready; + } + + // get cluster system meta, e.g. use in "logger" + getMeta() { + return { + id: this.id, + name: this.contextName, + ready: this.ready, + online: this.online, + accessible: this.accessible, + disconnected: this.disconnected, + }; + } + + /** + * broadcast an authentication update concerning this cluster + * @internal + */ + broadcastConnectUpdate(message: string, isError = false): void { + const update: KubeAuthUpdate = { message, isError }; + + this.dependencies.logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() }); + this.dependencies.broadcastMessage(`cluster:${this.id}:connection-update`, update); + } + + protected async requestAllowedNamespaces(proxyConfig: KubeConfig) { + if (this.accessibleNamespaces.length) { + return this.accessibleNamespaces; + } + + try { + const listNamespaces = this.dependencies.createListNamespaces(proxyConfig); + + return await listNamespaces(); + } catch (error) { + const ctx = proxyConfig.getContextObject(this.contextName); + const namespaceList = [ctx?.namespace].filter(isDefined); + + if (namespaceList.length === 0 && error instanceof HttpError && error.statusCode === 403) { + const { response } = error as HttpError & { response: Response }; + + this.dependencies.logger.info("[CLUSTER]: listing namespaces is forbidden, broadcasting", { clusterId: this.id, error: response.body }); + this.dependencies.broadcastMessage(clusterListNamespaceForbiddenChannel, this.id); + } + + return namespaceList; + } + } + + protected async getAllowedResources(requestNamespaceListPermissions: RequestNamespaceListPermissions) { + if (!this.allowedNamespaces.length) { + return []; + } + + try { + const apiLimit = plimit(5); // 5 concurrent api requests + const canListResourceCheckers = await Promise.all(( + this.allowedNamespaces.map(namespace => apiLimit(() => requestNamespaceListPermissions(namespace))) + )); + const canListNamespacedResource: CanListResource = (resource) => canListResourceCheckers.some(fn => fn(resource)); + + return this.knownResources + .filter(canListNamespacedResource) + .map(formatKubeApiResource); + } catch (error) { + return []; + } + } + + shouldShowResource(resource: KubeApiResourceDescriptor): boolean { + return this.allowedResources.has(formatKubeApiResource(resource)); + } + + isMetricHidden(resource: ClusterMetricsResourceType): boolean { + return Boolean(this.preferences.hiddenMetrics?.includes(resource)); + } + + get nodeShellImage(): string { + return this.preferences?.nodeShellImage || initialNodeShellImage; + } + + get imagePullSecret(): string | undefined { + return this.preferences?.imagePullSecret; + } + + isInLocalKubeconfig() { + return this.kubeConfigPath.startsWith(this.dependencies.directoryForKubeConfigs); + } +} diff --git a/packages/core/src/common/cluster/create-cluster-injection-token.ts b/packages/core/src/common/cluster/create-cluster-injection-token.ts new file mode 100644 index 0000000000..a07ce4459f --- /dev/null +++ b/packages/core/src/common/cluster/create-cluster-injection-token.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { ClusterConfigData, ClusterModel } from "../cluster-types"; +import type { Cluster } from "./cluster"; + +export type CreateCluster = (model: ClusterModel, configData: ClusterConfigData) => Cluster; + +export const createClusterInjectionToken = getInjectionToken({ + id: "create-cluster-token", +}); diff --git a/packages/core/src/common/cluster/current-cluster-channel.ts b/packages/core/src/common/cluster/current-cluster-channel.ts new file mode 100644 index 0000000000..957baa6f9c --- /dev/null +++ b/packages/core/src/common/cluster/current-cluster-channel.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ClusterId } from "../cluster-types"; +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export const currentClusterMessageChannel: MessageChannel = { + id: "current-visible-cluster", +}; diff --git a/packages/core/src/common/cluster/list-namespaces.injectable.ts b/packages/core/src/common/cluster/list-namespaces.injectable.ts new file mode 100644 index 0000000000..468ff3ac2e --- /dev/null +++ b/packages/core/src/common/cluster/list-namespaces.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeConfig } from "@kubernetes/client-node"; +import { CoreV1Api } from "@kubernetes/client-node"; +import { getInjectable } from "@ogre-tools/injectable"; +import { isDefined } from "../utils"; + +export type ListNamespaces = () => Promise; + +export function listNamespaces(config: KubeConfig): ListNamespaces { + const coreApi = config.makeApiClient(CoreV1Api); + + return async () => { + const { body: { items }} = await coreApi.listNamespace(); + + return items + .map(ns => ns.metadata?.name) + .filter(isDefined); + }; +} + +const listNamespacesInjectable = getInjectable({ + id: "list-namespaces", + instantiate: () => listNamespaces, +}); + +export default listNamespacesInjectable; diff --git a/packages/core/src/common/cluster/request-namespace-list-permissions.injectable.ts b/packages/core/src/common/cluster/request-namespace-list-permissions.injectable.ts new file mode 100644 index 0000000000..62d2477e42 --- /dev/null +++ b/packages/core/src/common/cluster/request-namespace-list-permissions.injectable.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeConfig } from "@kubernetes/client-node"; +import { AuthorizationV1Api } from "@kubernetes/client-node"; +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../logger.injectable"; +import type { KubeApiResource } from "../rbac"; + +export type CanListResource = (resource: KubeApiResource) => boolean; + +/** + * Requests the permissions for actions on the kube cluster + * @param namespace The namespace of the resources + */ +export type RequestNamespaceListPermissions = (namespace: string) => Promise; + +/** + * @param proxyConfig This config's `currentContext` field must be set, and will be used as the target cluster + */ +export type RequestNamespaceListPermissionsFor = (proxyConfig: KubeConfig) => RequestNamespaceListPermissions; + +const requestNamespaceListPermissionsForInjectable = getInjectable({ + id: "request-namespace-list-permissions-for", + instantiate: (di): RequestNamespaceListPermissionsFor => { + const logger = di.inject(loggerInjectable); + + return (proxyConfig) => { + const api = proxyConfig.makeApiClient(AuthorizationV1Api); + + return async (namespace) => { + try { + const { body: { status }} = await api.createSelfSubjectRulesReview({ + apiVersion: "authorization.k8s.io/v1", + kind: "SelfSubjectRulesReview", + spec: { namespace }, + }); + + if (!status || status.incomplete) { + logger.warn(`[AUTHORIZATION-NAMESPACE-REVIEW]: allowing all resources in namespace="${namespace}" due to incomplete SelfSubjectRulesReview: ${status?.evaluationError}`); + + return () => true; + } + + const { resourceRules } = status; + + return (resource) => { + const resourceRule = resourceRules.find(({ + apiGroups = [], + resources = [], + }) => { + const isAboutRelevantApiGroup = apiGroups.includes("*") || apiGroups.includes(resource.group); + const isAboutResource = resources.includes("*") || resources.includes(resource.apiName); + + return isAboutRelevantApiGroup && isAboutResource; + }); + + if (!resourceRule) { + return false; + } + + const { verbs } = resourceRule; + + return verbs.includes("*") || verbs.includes("list"); + }; + } catch (error) { + logger.error(`[AUTHORIZATION-NAMESPACE-REVIEW]: failed to create subject rules review`, { namespace, error }); + + return () => true; + } + }; + }; + }, +}); + +export default requestNamespaceListPermissionsForInjectable; diff --git a/packages/core/src/common/cluster/visibility-channel.ts b/packages/core/src/common/cluster/visibility-channel.ts new file mode 100644 index 0000000000..8a1a297ff2 --- /dev/null +++ b/packages/core/src/common/cluster/visibility-channel.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ClusterId } from "../cluster-types"; +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export const clusterVisibilityChannel: MessageChannel = { + id: "cluster-visibility", +}; diff --git a/packages/core/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts b/packages/core/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts new file mode 100644 index 0000000000..11a8f3f7de --- /dev/null +++ b/packages/core/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; + +const directoryForLensLocalStorageInjectable = getInjectable({ + id: "directory-for-lens-local-storage", + + instantiate: (di) => { + const joinPaths = di.inject(joinPathsInjectable); + const directoryForUserData = di.inject(directoryForUserDataInjectable); + + return joinPaths( + directoryForUserData, + "lens-local-storage", + ); + }, +}); + +export default directoryForLensLocalStorageInjectable; diff --git a/packages/core/src/common/error-reporting/initialize-sentry-reporting.global-override-for-injectable.ts b/packages/core/src/common/error-reporting/initialize-sentry-reporting.global-override-for-injectable.ts new file mode 100644 index 0000000000..1aa2934ef9 --- /dev/null +++ b/packages/core/src/common/error-reporting/initialize-sentry-reporting.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import initializeSentryReportingWithInjectable from "./initialize-sentry-reporting.injectable"; + +export default getGlobalOverride(initializeSentryReportingWithInjectable, () => () => {}); diff --git a/packages/core/src/common/error-reporting/initialize-sentry-reporting.injectable.ts b/packages/core/src/common/error-reporting/initialize-sentry-reporting.injectable.ts new file mode 100644 index 0000000000..778f959739 --- /dev/null +++ b/packages/core/src/common/error-reporting/initialize-sentry-reporting.injectable.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ElectronMainOptions } from "@sentry/electron/main"; +import type { BrowserOptions } from "@sentry/electron/renderer"; +import isProductionInjectable from "../vars/is-production.injectable"; +import sentryDataSourceNameInjectable from "../vars/sentry-dsn-url.injectable"; +import { Dedupe, Offline } from "@sentry/integrations"; +import { inspect } from "util"; +import userStoreInjectable from "../user-store/user-store.injectable"; + +export type InitializeSentryReportingWith = (initSentry: (opts: BrowserOptions | ElectronMainOptions) => void) => void; + +const mapProcessName = (type: "browser" | "renderer" | "worker") => type === "browser" ? "main" : type; + +const initializeSentryReportingWithInjectable = getInjectable({ + id: "initialize-sentry-reporting-with", + instantiate: (di): InitializeSentryReportingWith => { + const sentryDataSourceName = di.inject(sentryDataSourceNameInjectable); + const isProduction = di.inject(isProductionInjectable); + const userStore = di.inject(userStoreInjectable); + + if (!sentryDataSourceName) { + return () => {}; + } + + return (initSentry) => initSentry({ + beforeSend: (event) => { + if (userStore.allowErrorReporting) { + return event; + } + + /** + * Directly write to stdout so that no other integrations capture this and create an infinite loop + */ + process.stdout.write(`🔒 [SENTRY-BEFORE-SEND-HOOK]: Sentry event is caught but not sent to server.`); + process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === START OF SENTRY EVENT ==="); + process.stdout.write(inspect(event, false, null, true)); + process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === END OF SENTRY EVENT ==="); + + // if return null, the event won't be sent + // ref https://github.com/getsentry/sentry-javascript/issues/2039 + return null; + }, + dsn: sentryDataSourceName, + integrations: [ + new Dedupe(), + new Offline(), + ], + initialScope: { + tags: { + "process": mapProcessName(process.type), + }, + }, + environment: isProduction ? "production" : "development", + }); + }, + causesSideEffects: true, +}); + +export default initializeSentryReportingWithInjectable; diff --git a/packages/core/src/common/event-emitter.ts b/packages/core/src/common/event-emitter.ts new file mode 100644 index 0000000000..03f8e2754b --- /dev/null +++ b/packages/core/src/common/event-emitter.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Custom event emitter + +interface Options { + once?: boolean; // call once and remove + prepend?: boolean; // put listener to the beginning +} + +type Callback = (...data: D) => void | boolean; + +export class EventEmitter { + protected listeners: [Callback, Options][] = []; + + addListener(callback: Callback, options: Options = {}) { + const fn = options.prepend ? "unshift" : "push"; + + this.listeners[fn]([callback, options]); + } + + removeListener(callback: Callback) { + this.listeners = this.listeners.filter(([cb]) => cb !== callback); + } + + removeAllListeners() { + this.listeners.length = 0; + } + + emit(...data: D) { + for (const [callback, { once }] of this.listeners) { + if (once) { + this.removeListener(callback); + } + + if (callback(...data) === false) { + break; + } + } + } +} diff --git a/packages/core/src/common/fetch/download-binary.injectable.ts b/packages/core/src/common/fetch/download-binary.injectable.ts new file mode 100644 index 0000000000..27ef43d59b --- /dev/null +++ b/packages/core/src/common/fetch/download-binary.injectable.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { RequestInit, Response } from "node-fetch"; +import type { AsyncResult } from "../utils/async-result"; +import fetchInjectable from "./fetch.injectable"; + +export interface DownloadBinaryOptions { + signal?: AbortSignal | null | undefined; +} + +export type DownloadBinary = (url: string, opts?: DownloadBinaryOptions) => Promise>; + +const downloadBinaryInjectable = getInjectable({ + id: "download-binary", + instantiate: (di): DownloadBinary => { + const fetch = di.inject(fetchInjectable); + + return async (url, opts) => { + let result: Response; + + try { + // TODO: upgrade node-fetch once we switch to ESM + result = await fetch(url, opts as RequestInit); + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + + if (result.status < 200 || 300 <= result.status) { + return { + callWasSuccessful: false, + error: result.statusText, + }; + } + + try { + return { + callWasSuccessful: true, + response: await result.buffer(), + }; + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + }; + }, +}); + +export default downloadBinaryInjectable; diff --git a/packages/core/src/common/fetch/download-json/impl.ts b/packages/core/src/common/fetch/download-json/impl.ts new file mode 100644 index 0000000000..9faf9af124 --- /dev/null +++ b/packages/core/src/common/fetch/download-json/impl.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncResult } from "../../utils/async-result"; +import type { Fetch } from "../fetch.injectable"; +import type { RequestInit, Response } from "node-fetch"; + +export interface DownloadJsonOptions { + signal?: AbortSignal | null | undefined; +} + +export type DownloadJson = (url: string, opts?: DownloadJsonOptions) => Promise>; + +export const downloadJsonWith = (fetch: Fetch): DownloadJson => async (url, opts) => { + let result: Response; + + try { + result = await fetch(url, opts as RequestInit); + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + + if (result.status < 200 || 300 <= result.status) { + return { + callWasSuccessful: false, + error: result.statusText, + }; + } + + try { + return { + callWasSuccessful: true, + response: await result.json(), + }; + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } +}; + diff --git a/packages/core/src/common/fetch/download-json/normal.injectable.ts b/packages/core/src/common/fetch/download-json/normal.injectable.ts new file mode 100644 index 0000000000..adb5e35d82 --- /dev/null +++ b/packages/core/src/common/fetch/download-json/normal.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fetchInjectable from "../fetch.injectable"; +import { downloadJsonWith } from "./impl"; + +const downloadJsonInjectable = getInjectable({ + id: "download-json", + instantiate: (di) => downloadJsonWith(di.inject(fetchInjectable)), +}); + +export default downloadJsonInjectable; diff --git a/packages/core/src/common/fetch/download-json/proxy.injectable.ts b/packages/core/src/common/fetch/download-json/proxy.injectable.ts new file mode 100644 index 0000000000..46268d4ddb --- /dev/null +++ b/packages/core/src/common/fetch/download-json/proxy.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import proxyFetchInjectable from "../proxy-fetch.injectable"; +import { downloadJsonWith } from "./impl"; + +const proxyDownloadJsonInjectable = getInjectable({ + id: "proxy-download-json", + instantiate: (di) => downloadJsonWith(di.inject(proxyFetchInjectable)), +}); + +export default proxyDownloadJsonInjectable; diff --git a/packages/core/src/common/fetch/fetch-module.injectable.ts b/packages/core/src/common/fetch/fetch-module.injectable.ts new file mode 100644 index 0000000000..444333f196 --- /dev/null +++ b/packages/core/src/common/fetch/fetch-module.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type * as FetchModule from "node-fetch"; + +const { NodeFetch } = require("../../../build/webpack/node-fetch.bundle") as { NodeFetch: typeof FetchModule }; + +/** + * NOTE: while using this module can cause side effects, this specific injectable is not marked as + * such since sometimes the request can be wholely within the perview of unit test + */ +const nodeFetchModuleInjectable = getInjectable({ + id: "node-fetch-module", + instantiate: () => NodeFetch, +}); + +export default nodeFetchModuleInjectable; diff --git a/packages/core/src/common/fetch/fetch.global-override-for-injectable.ts b/packages/core/src/common/fetch/fetch.global-override-for-injectable.ts new file mode 100644 index 0000000000..1a5f80735c --- /dev/null +++ b/packages/core/src/common/fetch/fetch.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverrideForFunction } from "../test-utils/get-global-override-for-function"; +import fetchInjectable from "./fetch.injectable"; + +export default getGlobalOverrideForFunction(fetchInjectable); diff --git a/packages/core/src/common/fetch/fetch.injectable.ts b/packages/core/src/common/fetch/fetch.injectable.ts new file mode 100644 index 0000000000..d4f51efe0d --- /dev/null +++ b/packages/core/src/common/fetch/fetch.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { RequestInit, Response } from "node-fetch"; +import nodeFetchModuleInjectable from "./fetch-module.injectable"; + +export type Fetch = (url: string, init?: RequestInit) => Promise; + +const fetchInjectable = getInjectable({ + id: "fetch", + instantiate: (di): Fetch => { + const { default: fetch } = di.inject(nodeFetchModuleInjectable); + + return (url, init) => fetch(url, init); + }, + causesSideEffects: true, +}); + +export default fetchInjectable; diff --git a/packages/core/src/common/fetch/proxy-fetch.injectable.ts b/packages/core/src/common/fetch/proxy-fetch.injectable.ts new file mode 100644 index 0000000000..f13842c410 --- /dev/null +++ b/packages/core/src/common/fetch/proxy-fetch.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { HttpsProxyAgent } from "hpagent"; +import userStoreInjectable from "../user-store/user-store.injectable"; +import type { Fetch } from "./fetch.injectable"; +import fetchInjectable from "./fetch.injectable"; + +const proxyFetchInjectable = getInjectable({ + id: "proxy-fetch", + instantiate: (di): Fetch => { + const fetch = di.inject(fetchInjectable); + const { httpsProxy, allowUntrustedCAs } = di.inject(userStoreInjectable); + const agent = httpsProxy + ? new HttpsProxyAgent({ + proxy: httpsProxy, + rejectUnauthorized: !allowUntrustedCAs, + }) + : undefined; + + return (url, init = {}) => fetch(url, { + agent, + ...init, + }); + }, +}); + +export default proxyFetchInjectable; diff --git a/packages/core/src/common/fetch/timeout-controller.ts b/packages/core/src/common/fetch/timeout-controller.ts new file mode 100644 index 0000000000..702becdc9d --- /dev/null +++ b/packages/core/src/common/fetch/timeout-controller.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Creates an AbortController with an associated timeout + * @param timeout The number of milliseconds before this controller will auto abort + */ +export function withTimeout(timeout: number): AbortController { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + controller.signal.addEventListener("abort", () => clearTimeout(id)); + + return controller; +} diff --git a/packages/core/src/common/front-end-routing/app-navigation-channel.ts b/packages/core/src/common/front-end-routing/app-navigation-channel.ts new file mode 100644 index 0000000000..e0b881eff9 --- /dev/null +++ b/packages/core/src/common/front-end-routing/app-navigation-channel.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export type AppNavigationChannel = MessageChannel; + +export const appNavigationChannel: AppNavigationChannel = { + id: IpcRendererNavigationEvents.NAVIGATE_IN_APP, +}; diff --git a/packages/core/src/common/front-end-routing/cluster-frame-navigation-channel.ts b/packages/core/src/common/front-end-routing/cluster-frame-navigation-channel.ts new file mode 100644 index 0000000000..9d9a1904af --- /dev/null +++ b/packages/core/src/common/front-end-routing/cluster-frame-navigation-channel.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export type ClusterFrameNavigationChannel = MessageChannel; + +export const clusterFrameNavigationChannel: ClusterFrameNavigationChannel = { + id: IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER, +}; diff --git a/packages/core/src/common/front-end-routing/front-end-route-injection-token.ts b/packages/core/src/common/front-end-routing/front-end-route-injection-token.ts new file mode 100644 index 0000000000..cf1f9ed87e --- /dev/null +++ b/packages/core/src/common/front-end-routing/front-end-route-injection-token.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { LensRendererExtension } from "../../extensions/lens-renderer-extension"; + +export const frontEndRouteInjectionToken = getInjectionToken>({ + id: "front-end-route-injection-token", +}); + +export interface Route { + path: string; + clusterFrame: boolean; + isEnabled: IComputedValue; + extension?: LensRendererExtension; + + readonly parameterSignature?: TParameter; +} diff --git a/packages/core/src/common/front-end-routing/navigate-to-front-page.injectable.ts b/packages/core/src/common/front-end-routing/navigate-to-front-page.injectable.ts new file mode 100644 index 0000000000..a48eee6c04 --- /dev/null +++ b/packages/core/src/common/front-end-routing/navigate-to-front-page.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import navigateToCatalogInjectable from "./routes/catalog/navigate-to-catalog.injectable"; + +const navigateToFrontPageInjectable = getInjectable({ + id: "navigate-to-front-page", + instantiate: (di) => di.inject(navigateToCatalogInjectable), +}); + +export default navigateToFrontPageInjectable; diff --git a/packages/core/src/common/front-end-routing/navigate-to-route-injection-token.ts b/packages/core/src/common/front-end-routing/navigate-to-route-injection-token.ts new file mode 100644 index 0000000000..29fb82867c --- /dev/null +++ b/packages/core/src/common/front-end-routing/navigate-to-route-injection-token.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Route } from "./front-end-route-injection-token"; + +type InferParametersFrom = TRoute extends Route + ? TParameters + : never; + +type RequiredKeys = Exclude< + { + [K in keyof T]: T extends Record ? K : never; + }[keyof T], + undefined +>; + +type ObjectContainingNoRequired = T extends void + ? never + : RequiredKeys extends [] + ? any + : never; + +type ObjectContainsNoRequired = T extends ObjectContainingNoRequired + ? true + : false; + +// TODO: Missing types for: +// - Navigating to route without parameters, with parameters +// - Navigating to route with required parameters, without parameters +type Parameters = TParameters extends void + ? {} + : ObjectContainsNoRequired extends true + ? { parameters?: TParameters } + : { parameters: TParameters }; + +export type NavigateToRouteOptions = Parameters< + InferParametersFrom +> & { + query?: Record; + fragment?: string; + withoutAffectingBackButton?: boolean; +}; + +export type NavigateToRoute = >( + route: TRoute, + options?: NavigateToRouteOptions) => void; + +export const navigateToRouteInjectionToken = getInjectionToken( + { id: "navigate-to-route-injection-token" }, +); diff --git a/packages/core/src/common/front-end-routing/navigate-to-url-injection-token.ts b/packages/core/src/common/front-end-routing/navigate-to-url-injection-token.ts new file mode 100644 index 0000000000..560d62ce4a --- /dev/null +++ b/packages/core/src/common/front-end-routing/navigate-to-url-injection-token.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; + +export interface NavigateToUrlOptions { + withoutAffectingBackButton?: boolean; + forceRootFrame?: boolean; +} + +export type NavigateToUrl = (url: string, options?: NavigateToUrlOptions) => void; + +export const navigateToUrlInjectionToken = getInjectionToken( + { id: "navigate-to-url-injection-token" }, +); diff --git a/packages/core/src/common/front-end-routing/routes/add-cluster/add-cluster-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/add-cluster/add-cluster-route.injectable.ts new file mode 100644 index 0000000000..41e165dc59 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/add-cluster/add-cluster-route.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +const addClusterRouteInjectable = getInjectable({ + id: "add-cluster-route", + + instantiate: () => ({ + path: "/add-cluster", + clusterFrame: false, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default addClusterRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable.ts b/packages/core/src/common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable.ts new file mode 100644 index 0000000000..15595d51c2 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import addClusterRouteInjectable from "./add-cluster-route.injectable"; +import { navigateToRouteInjectionToken } from "../../navigate-to-route-injection-token"; + +const navigateToAddClusterInjectable = getInjectable({ + id: "navigate-to-add-cluster", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(addClusterRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToAddClusterInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/catalog/catalog-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/catalog/catalog-route.injectable.ts new file mode 100644 index 0000000000..bfe0904073 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/catalog/catalog-route.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { Route } from "../../front-end-route-injection-token"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +export interface CatalogPathParameters { + group?: string; + kind?: string; +} + +const catalogRouteInjectable = getInjectable({ + id: "catalog-route", + + instantiate: (): Route => ({ + path: "/catalog/:group?/:kind?", + clusterFrame: false, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default catalogRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/catalog/navigate-to-catalog.injectable.ts b/packages/core/src/common/front-end-routing/routes/catalog/navigate-to-catalog.injectable.ts new file mode 100644 index 0000000000..24fab385bb --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/catalog/navigate-to-catalog.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CatalogPathParameters } from "./catalog-route.injectable"; +import catalogRouteInjectable from "./catalog-route.injectable"; +import { navigateToRouteInjectionToken } from "../../navigate-to-route-injection-token"; + +export type NavigateToCatalog = (parameters?: CatalogPathParameters) => void; + +const navigateToCatalogInjectable = getInjectable({ + id: "navigate-to-catalog", + + instantiate: (di): NavigateToCatalog => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const catalogRoute = di.inject(catalogRouteInjectable); + + return (parameters) => + navigateToRoute(catalogRoute, { + parameters, + }); + }, +}); + +export default navigateToCatalogInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster-view/cluster-view-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster-view/cluster-view-route.injectable.ts new file mode 100644 index 0000000000..e912ff63e0 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster-view/cluster-view-route.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +const clusterViewRouteInjectable = getInjectable({ + id: "cluster-view-route", + + instantiate: () => ({ + path: "/cluster/:clusterId", + clusterFrame: false, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default clusterViewRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster-view/navigate-to-cluster-view.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster-view/navigate-to-cluster-view.injectable.ts new file mode 100644 index 0000000000..4edc3d3bb5 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster-view/navigate-to-cluster-view.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { navigateToRouteInjectionToken } from "../../navigate-to-route-injection-token"; +import clusterViewRouteInjectable from "./cluster-view-route.injectable"; + +export type NavigateToClusterView = (clusterId: string) => void; + +const navigateToClusterViewInjectable = getInjectable({ + id: "navigate-to-cluster-view", + + instantiate: (di): NavigateToClusterView => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(clusterViewRouteInjectable); + + return (clusterId) => + navigateToRoute(route, { parameters: { clusterId }}); + }, +}); + +export default navigateToClusterViewInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/config-maps/config-maps-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/config-maps/config-maps-route.injectable.ts new file mode 100644 index 0000000000..6ea03fff08 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/config-maps/config-maps-route.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const configMapsRouteInjectable = getInjectable({ + id: "config-maps-route", + instantiate: (di) => ({ + path: "/configmaps", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "configmaps", + group: "v1", + }), + }), + injectionToken: frontEndRouteInjectionToken, +}); + +export default configMapsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/config-maps/navigate-to-config-maps.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/config-maps/navigate-to-config-maps.injectable.ts new file mode 100644 index 0000000000..d4fa31027f --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/config-maps/navigate-to-config-maps.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import configMapsRouteInjectable from "./config-maps-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToConfigMapsInjectable = getInjectable({ + id: "navigate-to-config-maps", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(configMapsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToConfigMapsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/horizontal-pod-autoscalers-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/horizontal-pod-autoscalers-route.injectable.ts new file mode 100644 index 0000000000..00002620ee --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/horizontal-pod-autoscalers-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const horizontalPodAutoscalersRouteInjectable = getInjectable({ + id: "horizontal-pod-autoscalers-route", + + instantiate: (di) => ({ + path: "/hpa", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "horizontalpodautoscalers", + group: "autoscaling", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default horizontalPodAutoscalersRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/navigate-to-horizontal-pod-autoscalers.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/navigate-to-horizontal-pod-autoscalers.injectable.ts new file mode 100644 index 0000000000..ab171f4a08 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/navigate-to-horizontal-pod-autoscalers.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import horizontalPodAutoscalersRouteInjectable from "./horizontal-pod-autoscalers-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToHorizontalPodAutoscalersInjectable = getInjectable({ + id: "navigate-to-horizontal-pod-autoscalers", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(horizontalPodAutoscalersRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToHorizontalPodAutoscalersInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/leases/leases-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/leases/leases-route.injectable.ts new file mode 100644 index 0000000000..ea4eb2ae59 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/leases/leases-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const leasesRouteInjectable = getInjectable({ + id: "leases", + + instantiate: (di) => ({ + path: "/leases", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "leases", + group: "coordination.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default leasesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable.ts new file mode 100644 index 0000000000..5bf7d74ff1 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import leasesRouteInjectable from "./leases-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToLeasesInjectable = getInjectable({ + id: "navigate-to-leases", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(leasesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToLeasesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/limit-ranges/limit-ranges-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/limit-ranges/limit-ranges-route.injectable.ts new file mode 100644 index 0000000000..8623f3520e --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/limit-ranges/limit-ranges-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const limitRangesRouteInjectable = getInjectable({ + id: "limit-ranges-route", + + instantiate: (di) => ({ + path: "/limitranges", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "limitranges", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default limitRangesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/limit-ranges/navigate-to-limit-ranges.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/limit-ranges/navigate-to-limit-ranges.injectable.ts new file mode 100644 index 0000000000..00e8c89d7c --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/limit-ranges/navigate-to-limit-ranges.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import limitRangesRouteInjectable from "./limit-ranges-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToLimitRangesInjectable = getInjectable({ + id: "navigate-to-limit-ranges", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(limitRangesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToLimitRangesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/pod-disruption-budgets/navigate-to-pod-disruption-budgets.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/pod-disruption-budgets/navigate-to-pod-disruption-budgets.injectable.ts new file mode 100644 index 0000000000..5a3f17027d --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/pod-disruption-budgets/navigate-to-pod-disruption-budgets.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import podDisruptionBudgetsRouteInjectable from "./pod-disruption-budgets-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToPodDisruptionBudgetsInjectable = getInjectable({ + id: "navigate-to-pod-disruption-budgets", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(podDisruptionBudgetsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToPodDisruptionBudgetsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/pod-disruption-budgets/pod-disruption-budgets-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/pod-disruption-budgets/pod-disruption-budgets-route.injectable.ts new file mode 100644 index 0000000000..12ce0a2138 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/pod-disruption-budgets/pod-disruption-budgets-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const podDisruptionBudgetsRouteInjectable = getInjectable({ + id: "pod-disruption-budgets-route", + + instantiate: (di) => ({ + path: "/poddisruptionbudgets", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "poddisruptionbudgets", + group: "policy", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default podDisruptionBudgetsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/priority-classes/navigate-to-priority-classes.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/priority-classes/navigate-to-priority-classes.injectable.ts new file mode 100644 index 0000000000..45b7a34eeb --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/priority-classes/navigate-to-priority-classes.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import priorityClassesRouteInjectable from "./priority-classes-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToPriorityClassesInjectable = getInjectable({ + id: "navigate-to-priority-classes", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(priorityClassesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToPriorityClassesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/priority-classes/priority-classes-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/priority-classes/priority-classes-route.injectable.ts new file mode 100644 index 0000000000..75194b0541 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/priority-classes/priority-classes-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const priorityClassesRouteInjectable = getInjectable({ + id: "priority-classes-route", + + instantiate: (di) => ({ + path: "/priorityclasses", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "priorityclasses", + group: "scheduling.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default priorityClassesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/resource-quotas/navigate-to-resource-quotas.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/resource-quotas/navigate-to-resource-quotas.injectable.ts new file mode 100644 index 0000000000..3637782115 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/resource-quotas/navigate-to-resource-quotas.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import resourceQuotasRouteInjectable from "./resource-quotas-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToResourceQuotasInjectable = getInjectable({ + id: "navigate-to-resource-quotas", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(resourceQuotasRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToResourceQuotasInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/resource-quotas/resource-quotas-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/resource-quotas/resource-quotas-route.injectable.ts new file mode 100644 index 0000000000..209f77e19a --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/resource-quotas/resource-quotas-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const resourceQuotasRouteInjectable = getInjectable({ + id: "resource-quotas-route", + + instantiate: (di) => ({ + path: "/resourcequotas", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "resourcequotas", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default resourceQuotasRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/runtime-classes/navigate-to-runtime-classes.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/runtime-classes/navigate-to-runtime-classes.injectable.ts new file mode 100644 index 0000000000..4d6ca1757d --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/runtime-classes/navigate-to-runtime-classes.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import runtimeClassesRouteInjectable from "./runtime-classes-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToRuntimeClassesInjectable = getInjectable({ + id: "navigate-to-runtime-classes", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(runtimeClassesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToRuntimeClassesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable.ts new file mode 100644 index 0000000000..beab83754f --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const runtimeClassesRouteInjectable = getInjectable({ + id: "runtime-classes-route", + + instantiate: (di) => ({ + path: "/runtimeclasses", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "runtimeclasses", + group: "node.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default runtimeClassesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/secrets/navigate-to-secrets.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/secrets/navigate-to-secrets.injectable.ts new file mode 100644 index 0000000000..b8afdf74c3 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/secrets/navigate-to-secrets.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import secretsRouteInjectable from "./secrets-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToSecretsInjectable = getInjectable({ + id: "navigate-to-secrets", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(secretsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToSecretsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/config/secrets/secrets-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/config/secrets/secrets-route.injectable.ts new file mode 100644 index 0000000000..079ddcbf83 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/config/secrets/secrets-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const secretsRouteInjectable = getInjectable({ + id: "secrets-route", + + instantiate: (di) => ({ + path: "/secrets", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "secrets", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default secretsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/crd-list/crd-list-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/crd-list/crd-list-route.injectable.ts new file mode 100644 index 0000000000..854b3a8c3a --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/crd-list/crd-list-route.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const crdListRouteInjectable = getInjectable({ + id: "crd-list-route", + + instantiate: () => ({ + path: "/crd/definitions", + clusterFrame: true, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default crdListRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/crd-list/navigate-to-crd-list.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/crd-list/navigate-to-crd-list.injectable.ts new file mode 100644 index 0000000000..bd78dd1174 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/crd-list/navigate-to-crd-list.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import crdListRouteInjectable from "./crd-list-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToCrdListInjectable = getInjectable({ + id: "navigate-to-crd-list", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(crdListRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToCrdListInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable.ts new file mode 100644 index 0000000000..cc7d9b40be --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { Route } from "../../../../front-end-route-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +export interface CustomResourcesPathParameters { + group?: string; + name?: string; +} + +const customResourcesRouteInjectable = getInjectable({ + id: "custom-resources-route", + + instantiate: (): Route => ({ + path: "/crd/:group?/:name?", + clusterFrame: true, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default customResourcesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/custom-resources/navigate-to-custom-resources.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/custom-resources/navigate-to-custom-resources.injectable.ts new file mode 100644 index 0000000000..9df8cd6e2c --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/custom-resources/custom-resources/navigate-to-custom-resources.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CustomResourcesPathParameters } from "./custom-resources-route.injectable"; +import customResourcesRouteInjectable from "./custom-resources-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToCustomResourcesInjectable = getInjectable({ + id: "navigate-to-custom-resources", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(customResourcesRouteInjectable); + + return (parameters?: CustomResourcesPathParameters) => + navigateToRoute(route, { parameters }); + }, +}); + +export default navigateToCustomResourcesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/events/events-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/events/events-route.injectable.ts new file mode 100644 index 0000000000..b3df358ad8 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/events/events-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; + +const eventsRouteInjectable = getInjectable({ + id: "events-route", + + instantiate: (di) => ({ + path: "/events", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "events", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default eventsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/events/navigate-to-events.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/events/navigate-to-events.injectable.ts new file mode 100644 index 0000000000..767f69b497 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/events/navigate-to-events.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import eventsRouteInjectable from "./events-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../navigate-to-route-injection-token"; + +const navigateToEventsInjectable = getInjectable({ + id: "navigate-to-events", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(eventsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToEventsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/helm/charts/helm-charts-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/helm/charts/helm-charts-route.injectable.ts new file mode 100644 index 0000000000..02161b082a --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/helm/charts/helm-charts-route.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { Route } from "../../../../front-end-route-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +export interface HelmChartsPathParameters { + repo?: string; + chartName?: string; +} + +const helmChartsRouteInjectable = getInjectable({ + id: "helm-charts-route", + + instantiate: (): Route => ({ + path: "/helm/charts/:repo?/:chartName?", + clusterFrame: true, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default helmChartsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable.ts new file mode 100644 index 0000000000..37ab96cc2c --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { HelmChartsPathParameters } from "./helm-charts-route.injectable"; +import helmChartsRouteInjectable from "./helm-charts-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +export type NavigateToHelmCharts = (parameters?: HelmChartsPathParameters) => void; + +const navigateToHelmChartsInjectable = getInjectable({ + id: "navigate-to-helm-charts", + + instantiate: (di): NavigateToHelmCharts => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(helmChartsRouteInjectable); + + return (parameters) => + navigateToRoute(route, { + parameters, + }); + }, +}); + +export default navigateToHelmChartsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/helm/releases/helm-releases-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/helm/releases/helm-releases-route.injectable.ts new file mode 100644 index 0000000000..fb66416c63 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/helm/releases/helm-releases-route.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { Route } from "../../../../front-end-route-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +export interface HelmReleasesPathParameters { + namespace?: string; + name?: string; +} + +const helmReleasesRouteInjectable = getInjectable({ + id: "helm-releases-route", + + instantiate: (): Route => ({ + path: "/helm/releases/:namespace?/:name?", + clusterFrame: true, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default helmReleasesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable.ts new file mode 100644 index 0000000000..3d9aebddf5 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { HelmReleasesPathParameters } from "./helm-releases-route.injectable"; +import helmReleasesRouteInjectable from "./helm-releases-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +export type NavigateToHelmReleases = (parameters?: HelmReleasesPathParameters) => void; + +const navigateToHelmReleasesInjectable = getInjectable({ + id: "navigate-to-helm-releases", + + instantiate: (di): NavigateToHelmReleases => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(helmReleasesRouteInjectable); + + return (parameters) => navigateToRoute(route, { parameters }); + }, +}); + +export default navigateToHelmReleasesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/namespaces/namespaces-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/namespaces/namespaces-route.injectable.ts new file mode 100644 index 0000000000..2aa6c23efe --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/namespaces/namespaces-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; + +const namespacesRouteInjectable = getInjectable({ + id: "namespaces-route", + + instantiate: (di) => ({ + path: "/namespaces", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "namespaces", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default namespacesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/namespaces/navigate-to-namespaces.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/namespaces/navigate-to-namespaces.injectable.ts new file mode 100644 index 0000000000..85fa63c695 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/namespaces/navigate-to-namespaces.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import namespacesRouteInjectable from "./namespaces-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../navigate-to-route-injection-token"; + +const navigateToNamespacesInjectable = getInjectable({ + id: "navigate-to-namespaces", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(namespacesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToNamespacesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/endpoints/endpoints-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/endpoints/endpoints-route.injectable.ts new file mode 100644 index 0000000000..c88ec04714 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/endpoints/endpoints-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const endpointsRouteInjectable = getInjectable({ + id: "endpoints-route", + + instantiate: (di) => ({ + path: "/endpoints", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "endpoints", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default endpointsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/endpoints/navigate-to-endpoints.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/endpoints/navigate-to-endpoints.injectable.ts new file mode 100644 index 0000000000..04439b3d3b --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/endpoints/navigate-to-endpoints.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import endpointsRouteInjectable from "./endpoints-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToEndpointsInjectable = getInjectable({ + id: "navigate-to-endpoints", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(endpointsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToEndpointsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/ingress-class/ingress-classeses-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/ingress-class/ingress-classeses-route.injectable.ts new file mode 100644 index 0000000000..e036d4b6a6 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/ingress-class/ingress-classeses-route.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; +import { + shouldShowResourceInjectionToken, +} from "../../../../../cluster-store/allowed-resources-injection-token"; + +const ingressClassesesRouteInjectable = getInjectable({ + id: "ingress-classes-route", + + instantiate: (di) => { + const isEnabled = di.inject(shouldShowResourceInjectionToken, { + apiName: "ingressclasses", + group: "networking.k8s.io", + }); + + return { + path: "/ingress-classes", + clusterFrame: true, + isEnabled, + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default ingressClassesesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/ingress-class/navigate-to-ingress-classes.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/ingress-class/navigate-to-ingress-classes.injectable.ts new file mode 100644 index 0000000000..22c666d5ae --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/ingress-class/navigate-to-ingress-classes.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; +import ingressClassesesRouteInjectable from "./ingress-classeses-route.injectable"; + +const navigateToIngressesInjectable = getInjectable({ + id: "navigate-to-ingress-classes", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(ingressClassesesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToIngressesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/ingresses/ingresses-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/ingresses/ingresses-route.injectable.ts new file mode 100644 index 0000000000..8e01646b82 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/ingresses/ingresses-route.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { computedOr } from "../../../../../utils/computed-or"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const ingressesRouteInjectable = getInjectable({ + id: "ingresses-route", + + instantiate: (di) => ({ + path: "/ingresses", + clusterFrame: true, + isEnabled: computedOr( + di.inject(shouldShowResourceInjectionToken, { + apiName: "ingresses", + group: "networking.k8s.io", + }), + di.inject(shouldShowResourceInjectionToken, { + apiName: "ingresses", + group: "extensions", + }), + ), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default ingressesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/ingresses/navigate-to-ingresses.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/ingresses/navigate-to-ingresses.injectable.ts new file mode 100644 index 0000000000..b47f9d4be3 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/ingresses/navigate-to-ingresses.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import ingressesRouteInjectable from "./ingresses-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToIngressesInjectable = getInjectable({ + id: "navigate-to-ingresses", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(ingressesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToIngressesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/network-policies/navigate-to-network-policies.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/network-policies/navigate-to-network-policies.injectable.ts new file mode 100644 index 0000000000..82bc76ee9f --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/network-policies/navigate-to-network-policies.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import networkPoliciesRouteInjectable from "./network-policies-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToNetworkPoliciesInjectable = getInjectable({ + id: "navigate-to-network-policies", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(networkPoliciesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToNetworkPoliciesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/network-policies/network-policies-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/network-policies/network-policies-route.injectable.ts new file mode 100644 index 0000000000..38a1b8a7e2 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/network-policies/network-policies-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const networkPoliciesRouteInjectable = getInjectable({ + id: "network-policies-route", + + instantiate: (di) => ({ + path: "/network-policies", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "networkpolicies", + group: "networking.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default networkPoliciesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/port-forwards/navigate-to-port-forwards.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/port-forwards/navigate-to-port-forwards.injectable.ts new file mode 100644 index 0000000000..9a03eeda47 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/port-forwards/navigate-to-port-forwards.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { PortForwardsPathParameters } from "./port-forwards-route.injectable"; +import portForwardsRouteInjectable from "./port-forwards-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +export type NavigateToPortForwards = (parameters?: PortForwardsPathParameters) => void; + +const navigateToPortForwardsInjectable = getInjectable({ + id: "navigate-to-port-forwards", + + instantiate: (di): NavigateToPortForwards => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(portForwardsRouteInjectable); + + return (parameters) => + navigateToRoute(route, { parameters }); + }, +}); + +export default navigateToPortForwardsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/port-forwards/port-forwards-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/port-forwards/port-forwards-route.injectable.ts new file mode 100644 index 0000000000..0c8cd5ef2d --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/port-forwards/port-forwards-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { Route } from "../../../../front-end-route-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +export interface PortForwardsPathParameters { + forwardport?: string; +} + +const portForwardsRouteInjectable = getInjectable({ + id: "port-forwards-route", + + instantiate: (): Route => ({ + path: "/port-forwards/:forwardport?", + clusterFrame: true, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default portForwardsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/services/navigate-to-services.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/services/navigate-to-services.injectable.ts new file mode 100644 index 0000000000..02aa060b89 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/services/navigate-to-services.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import servicesRouteInjectable from "./services-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToServicesInjectable = getInjectable({ + id: "navigate-to-services", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(servicesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToServicesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/network/services/services-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/network/services/services-route.injectable.ts new file mode 100644 index 0000000000..53300ee241 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/network/services/services-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const servicesRouteInjectable = getInjectable({ + id: "services-route", + + instantiate: (di) => ({ + path: "/services", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "services", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default servicesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/nodes/navigate-to-nodes.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/nodes/navigate-to-nodes.injectable.ts new file mode 100644 index 0000000000..eccdd2377d --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/nodes/navigate-to-nodes.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import nodesRouteInjectable from "./nodes-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../navigate-to-route-injection-token"; + +const navigateToNodesInjectable = getInjectable({ + id: "navigate-to-nodes", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(nodesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToNodesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/nodes/nodes-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/nodes/nodes-route.injectable.ts new file mode 100644 index 0000000000..81323843d5 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/nodes/nodes-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; + +const nodesRouteInjectable = getInjectable({ + id: "nodes-route", + + instantiate: (di) => ({ + path: "/nodes", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "nodes", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default nodesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable.ts new file mode 100644 index 0000000000..8315fd7773 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../front-end-route-injection-token"; + +const clusterOverviewRouteInjectable = getInjectable({ + id: "cluster-overview-route", + + instantiate: (di) => ({ + path: "/overview", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "nodes", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default clusterOverviewRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/overview/navigate-to-cluster-overview.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/overview/navigate-to-cluster-overview.injectable.ts new file mode 100644 index 0000000000..f15dca518c --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/overview/navigate-to-cluster-overview.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import clusterOverviewRouteInjectable from "./cluster-overview-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../navigate-to-route-injection-token"; + +const navigateToClusterOverviewInjectable = getInjectable({ + id: "navigate-to-cluster-overview", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(clusterOverviewRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToClusterOverviewInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volume-claims/navigate-to-persistent-volume-claims.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volume-claims/navigate-to-persistent-volume-claims.injectable.ts new file mode 100644 index 0000000000..015ad6e988 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volume-claims/navigate-to-persistent-volume-claims.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import persistentVolumeClaimsRouteInjectable from "./persistent-volume-claims-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToPersistentVolumeClaimsInjectable = getInjectable({ + id: "navigate-to-persistent-volume-claims", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(persistentVolumeClaimsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToPersistentVolumeClaimsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volume-claims/persistent-volume-claims-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volume-claims/persistent-volume-claims-route.injectable.ts new file mode 100644 index 0000000000..1b96933136 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volume-claims/persistent-volume-claims-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const persistentVolumeClaimsRouteInjectable = getInjectable({ + id: "persistent-volume-claims-route", + + instantiate: (di) => ({ + path: "/persistent-volume-claims", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "persistentvolumeclaims", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default persistentVolumeClaimsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volumes/navigate-to-persistent-volumes.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volumes/navigate-to-persistent-volumes.injectable.ts new file mode 100644 index 0000000000..6702dd6712 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volumes/navigate-to-persistent-volumes.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import persistentVolumesRouteInjectable from "./persistent-volumes-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToPersistentVolumesInjectable = getInjectable({ + id: "navigate-to-persistent-volumes", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(persistentVolumesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToPersistentVolumesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volumes/persistent-volumes-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volumes/persistent-volumes-route.injectable.ts new file mode 100644 index 0000000000..52f95b32c6 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/storage/persistent-volumes/persistent-volumes-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const persistentVolumesRouteInjectable = getInjectable({ + id: "persistent-volumes-route", + + instantiate: (di) => ({ + path: "/persistent-volumes", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "persistentvolumes", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default persistentVolumesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/storage/storage-classes/navigate-to-storage-classes.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/storage/storage-classes/navigate-to-storage-classes.injectable.ts new file mode 100644 index 0000000000..adaba3225b --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/storage/storage-classes/navigate-to-storage-classes.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import storageClassesRouteInjectable from "./storage-classes-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToStorageClassesInjectable = getInjectable({ + id: "navigate-to-storage-classes", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(storageClassesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToStorageClassesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/storage/storage-classes/storage-classes-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/storage/storage-classes/storage-classes-route.injectable.ts new file mode 100644 index 0000000000..8702ab1602 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/storage/storage-classes/storage-classes-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const storageClassesRouteInjectable = getInjectable({ + id: "storage-classes-route", + + instantiate: (di) => ({ + path: "/storage-classes", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "storageclasses", + group: "storage.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default storageClassesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-role-bindings/cluster-role-bindings-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-role-bindings/cluster-role-bindings-route.injectable.ts new file mode 100644 index 0000000000..0903d5fced --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-role-bindings/cluster-role-bindings-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const clusterRoleBindingsRouteInjectable = getInjectable({ + id: "cluster-role-bindings-route", + + instantiate: (di) => ({ + path: "/cluster-role-bindings", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "clusterrolebindings", + group: "rbac.authorization.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default clusterRoleBindingsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-role-bindings/navigate-to-cluster-role-bindings.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-role-bindings/navigate-to-cluster-role-bindings.injectable.ts new file mode 100644 index 0000000000..f6bd0bfd97 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-role-bindings/navigate-to-cluster-role-bindings.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import clusterRoleBindingsRouteInjectable from "./cluster-role-bindings-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToClusterRoleBindingsInjectable = getInjectable({ + id: "navigate-to-cluster-role-bindings", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(clusterRoleBindingsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToClusterRoleBindingsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-roles/cluster-roles-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-roles/cluster-roles-route.injectable.ts new file mode 100644 index 0000000000..9fce206667 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-roles/cluster-roles-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const clusterRolesRouteInjectable = getInjectable({ + id: "cluster-roles-route", + + instantiate: (di) => ({ + path: "/cluster-roles", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "clusterroles", + group: "rbac.authorization.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default clusterRolesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-roles/navigate-to-cluster-roles.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-roles/navigate-to-cluster-roles.injectable.ts new file mode 100644 index 0000000000..ed56679155 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/cluster-roles/navigate-to-cluster-roles.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import clusterRolesRouteInjectable from "./cluster-roles-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToClusterRolesInjectable = getInjectable({ + id: "navigate-to-cluster-roles", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(clusterRolesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToClusterRolesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/pod-security-policies/navigate-to-pod-security-policies.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/pod-security-policies/navigate-to-pod-security-policies.injectable.ts new file mode 100644 index 0000000000..205354ba3b --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/pod-security-policies/navigate-to-pod-security-policies.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import podSecurityPoliciesRouteInjectable from "./pod-security-policies-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToPodSecurityPoliciesInjectable = getInjectable({ + id: "navigate-to-pod-security-policies", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(podSecurityPoliciesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToPodSecurityPoliciesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/pod-security-policies/pod-security-policies-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/pod-security-policies/pod-security-policies-route.injectable.ts new file mode 100644 index 0000000000..2f35986916 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/pod-security-policies/pod-security-policies-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const podSecurityPoliciesRouteInjectable = getInjectable({ + id: "pod-security-policies-route", + + instantiate: (di) => { + return { + path: "/pod-security-policies", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "podsecuritypolicies", + group: "policy", + }), + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default podSecurityPoliciesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/role-bindings/navigate-to-role-bindings.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/role-bindings/navigate-to-role-bindings.injectable.ts new file mode 100644 index 0000000000..ce5bb3713b --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/role-bindings/navigate-to-role-bindings.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import roleBindingsRouteInjectable from "./role-bindings-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToRoleBindingsInjectable = getInjectable({ + id: "navigate-to-role-bindings", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(roleBindingsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToRoleBindingsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/role-bindings/role-bindings-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/role-bindings/role-bindings-route.injectable.ts new file mode 100644 index 0000000000..759c1b8eda --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/role-bindings/role-bindings-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const roleBindingsRouteInjectable = getInjectable({ + id: "role-bindings-route", + + instantiate: (di) => { + return { + path: "/role-bindings", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "rolebindings", + group: "rbac.authorization.k8s.io", + }), + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default roleBindingsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/roles/navigate-to-roles.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/roles/navigate-to-roles.injectable.ts new file mode 100644 index 0000000000..adebb64ae1 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/roles/navigate-to-roles.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import rolesRouteInjectable from "./roles-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToRolesInjectable = getInjectable({ + id: "navigate-to-roles", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(rolesRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToRolesInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/roles/roles-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/roles/roles-route.injectable.ts new file mode 100644 index 0000000000..efe4cad810 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/roles/roles-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const rolesRouteInjectable = getInjectable({ + id: "roles-route", + + instantiate: (di) => ({ + path: "/roles", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "roles", + group: "rbac.authorization.k8s.io", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default rolesRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/service-accounts/navigate-to-service-accounts.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/service-accounts/navigate-to-service-accounts.injectable.ts new file mode 100644 index 0000000000..5eb1861a92 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/service-accounts/navigate-to-service-accounts.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import serviceAccountsRouteInjectable from "./service-accounts-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToServiceAccountsInjectable = getInjectable({ + id: "navigate-to-service-accounts", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(serviceAccountsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToServiceAccountsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/user-management/service-accounts/service-accounts-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/user-management/service-accounts/service-accounts-route.injectable.ts new file mode 100644 index 0000000000..3bf6c1ec00 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/user-management/service-accounts/service-accounts-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const serviceAccountsRouteInjectable = getInjectable({ + id: "service-accounts-route", + + instantiate: (di) => ({ + path: "/service-accounts", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "serviceaccounts", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default serviceAccountsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/cron-jobs/cron-jobs-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/cron-jobs/cron-jobs-route.injectable.ts new file mode 100644 index 0000000000..33453a2247 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/cron-jobs/cron-jobs-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const cronJobsRouteInjectable = getInjectable({ + id: "cron-jobs-route", + + instantiate: (di) => ({ + path: "/cronjobs", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "cronjobs", + group: "batch", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default cronJobsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/cron-jobs/navigate-to-cron-jobs.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/cron-jobs/navigate-to-cron-jobs.injectable.ts new file mode 100644 index 0000000000..42b104055e --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/cron-jobs/navigate-to-cron-jobs.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import cronJobsRouteInjectable from "./cron-jobs-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToCronJobsInjectable = getInjectable({ + id: "navigate-to-cron-jobs", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(cronJobsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToCronJobsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/daemonsets/daemonsets-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/daemonsets/daemonsets-route.injectable.ts new file mode 100644 index 0000000000..f1ec2008fa --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/daemonsets/daemonsets-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const daemonsetsRouteInjectable = getInjectable({ + id: "daemonsets-route", + + instantiate: (di) => ({ + path: "/daemonsets", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "daemonsets", + group: "apps", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default daemonsetsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/daemonsets/navigate-to-daemonsets.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/daemonsets/navigate-to-daemonsets.injectable.ts new file mode 100644 index 0000000000..1cc5ec8d8d --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/daemonsets/navigate-to-daemonsets.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import daemonsetsRouteInjectable from "./daemonsets-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToDaemonsetsInjectable = getInjectable({ + id: "navigate-to-daemonsets", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(daemonsetsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToDaemonsetsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/deployments/deployments-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/deployments/deployments-route.injectable.ts new file mode 100644 index 0000000000..84c059780f --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/deployments/deployments-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const deploymentsRouteInjectable = getInjectable({ + id: "deployments-route", + + instantiate: (di) => ({ + path: "/deployments", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "deployments", + group: "apps", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default deploymentsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/deployments/navigate-to-deployments.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/deployments/navigate-to-deployments.injectable.ts new file mode 100644 index 0000000000..b6d2f07391 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/deployments/navigate-to-deployments.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import deploymentsRouteInjectable from "./deployments-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToDeploymentsInjectable = getInjectable({ + id: "navigate-to-deployments", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(deploymentsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToDeploymentsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/jobs/jobs-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/jobs/jobs-route.injectable.ts new file mode 100644 index 0000000000..39cc89e88f --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/jobs/jobs-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const jobsRouteInjectable = getInjectable({ + id: "jobs-route", + + instantiate: (di) => ({ + path: "/jobs", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "jobs", + group: "batch", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default jobsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/jobs/navigate-to-jobs.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/jobs/navigate-to-jobs.injectable.ts new file mode 100644 index 0000000000..920c5c4de7 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/jobs/navigate-to-jobs.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import jobsRouteInjectable from "./jobs-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToJobsInjectable = getInjectable({ + id: "navigate-to-jobs", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(jobsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToJobsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/overview/navigate-to-workloads-overview.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/overview/navigate-to-workloads-overview.injectable.ts new file mode 100644 index 0000000000..224950206b --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/overview/navigate-to-workloads-overview.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import workloadsOverviewRouteInjectable from "./workloads-overview-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToWorkloadsOverviewInjectable = getInjectable({ + id: "navigate-to-workloads-overview", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(workloadsOverviewRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToWorkloadsOverviewInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/overview/workloads-overview-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/overview/workloads-overview-route.injectable.ts new file mode 100644 index 0000000000..188bd9f1de --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/overview/workloads-overview-route.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const workloadsOverviewRouteInjectable = getInjectable({ + id: "workloads-overview-route", + + instantiate: () => ({ + path: "/workloads", + clusterFrame: true, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default workloadsOverviewRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/pods/navigate-to-pods.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/pods/navigate-to-pods.injectable.ts new file mode 100644 index 0000000000..4eab211598 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/pods/navigate-to-pods.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import podsRouteInjectable from "./pods-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToPodsInjectable = getInjectable({ + id: "navigate-to-pods", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(podsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToPodsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/pods/pods-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/pods/pods-route.injectable.ts new file mode 100644 index 0000000000..577f1c1a91 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/pods/pods-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const podsRouteInjectable = getInjectable({ + id: "pods-route", + + instantiate: (di) => ({ + path: "/pods", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "pods", + group: "v1", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default podsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/replicasets/navigate-to-replicasets.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/replicasets/navigate-to-replicasets.injectable.ts new file mode 100644 index 0000000000..9549abab09 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/replicasets/navigate-to-replicasets.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import replicasetsRouteInjectable from "./replicasets-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToReplicasetsInjectable = getInjectable({ + id: "navigate-to-replicasets", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(replicasetsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToReplicasetsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/replicasets/replicasets-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/replicasets/replicasets-route.injectable.ts new file mode 100644 index 0000000000..b790ce13ec --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/replicasets/replicasets-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const replicasetsRouteInjectable = getInjectable({ + id: "replicasets-route", + + instantiate: (di) => ({ + path: "/replicasets", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "replicasets", + group: "apps", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default replicasetsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/statefulsets/navigate-to-statefulsets.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/statefulsets/navigate-to-statefulsets.injectable.ts new file mode 100644 index 0000000000..403c7fa042 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/statefulsets/navigate-to-statefulsets.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import statefulsetsRouteInjectable from "./statefulsets-route.injectable"; +import { navigateToRouteInjectionToken } from "../../../../navigate-to-route-injection-token"; + +const navigateToStatefulsetsInjectable = getInjectable({ + id: "navigate-to-statefulsets", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(statefulsetsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToStatefulsetsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/cluster/workloads/statefulsets/statefulsets-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/cluster/workloads/statefulsets/statefulsets-route.injectable.ts new file mode 100644 index 0000000000..72c81b3bee --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/cluster/workloads/statefulsets/statefulsets-route.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shouldShowResourceInjectionToken } from "../../../../../cluster-store/allowed-resources-injection-token"; +import { frontEndRouteInjectionToken } from "../../../../front-end-route-injection-token"; + +const statefulsetsRouteInjectable = getInjectable({ + id: "statefulsets-route", + + instantiate: (di) => ({ + path: "/statefulsets", + clusterFrame: true, + isEnabled: di.inject(shouldShowResourceInjectionToken, { + apiName: "statefulsets", + group: "apps", + }), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default statefulsetsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/entity-settings/entity-settings-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/entity-settings/entity-settings-route.injectable.ts new file mode 100644 index 0000000000..e7d3ac5f46 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/entity-settings/entity-settings-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { Route } from "../../front-end-route-injection-token"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +export interface EntitySettingsPathParameters { + entityId: string; +} + +const entitySettingsRouteInjectable = getInjectable({ + id: "entity-settings-route", + + instantiate: (): Route => ({ + path: "/entity/:entityId/settings", + clusterFrame: false, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default entitySettingsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable.ts b/packages/core/src/common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable.ts new file mode 100644 index 0000000000..bc49bc4041 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import entitySettingsRouteInjectable from "./entity-settings-route.injectable"; +import { navigateToRouteInjectionToken } from "../../navigate-to-route-injection-token"; + +export type NavigateToEntitySettings = (entityId: string, targetTabId?: string) => void; + +const navigateToEntitySettingsInjectable = getInjectable({ + id: "navigate-to-entity-settings", + + instantiate: (di): NavigateToEntitySettings => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(entitySettingsRouteInjectable); + + return (entityId, targetTabId) => + navigateToRoute(route, { parameters: { entityId }, fragment: targetTabId }); + }, +}); + +export default navigateToEntitySettingsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/extensions/extensions-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/extensions/extensions-route.injectable.ts new file mode 100644 index 0000000000..5992355022 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/extensions/extensions-route.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +const extensionsRouteInjectable = getInjectable({ + id: "extensions-route", + + instantiate: () => ({ + path: "/extensions", + clusterFrame: false, + isEnabled: computed(() => true), + }), + + injectionToken: frontEndRouteInjectionToken, +}); + +export default extensionsRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/extensions/navigate-to-extensions.injectable.ts b/packages/core/src/common/front-end-routing/routes/extensions/navigate-to-extensions.injectable.ts new file mode 100644 index 0000000000..2e4e6d7d4c --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/extensions/navigate-to-extensions.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import extensionsRouteInjectable from "./extensions-route.injectable"; +import { navigateToRouteInjectionToken } from "../../navigate-to-route-injection-token"; + +const navigateToExtensionsInjectable = getInjectable({ + id: "navigate-to-extensions", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(extensionsRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToExtensionsInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/welcome/default-welcome-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/welcome/default-welcome-route.injectable.ts new file mode 100644 index 0000000000..d8db889033 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/welcome/default-welcome-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import welcomeRouteConfigInjectable from "./welcome-route-config.injectable"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +const defaultWelcomeRouteInjectable = getInjectable({ + id: "default-welcome-route", + + instantiate: (di) => { + const welcomeRoute = di.inject(welcomeRouteConfigInjectable); + + return { + path: "/welcome", + clusterFrame: false, + isEnabled: computed(() => welcomeRoute === "/welcome"), + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default defaultWelcomeRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/welcome/navigate-to-welcome.injectable.ts b/packages/core/src/common/front-end-routing/routes/welcome/navigate-to-welcome.injectable.ts new file mode 100644 index 0000000000..46f5b42056 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/welcome/navigate-to-welcome.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import welcomeRouteInjectable from "./welcome-route.injectable"; +import { navigateToRouteInjectionToken } from "../../navigate-to-route-injection-token"; + +const navigateToWelcomeInjectable = getInjectable({ + id: "navigate-to-welcome", + + instantiate: (di) => { + const navigateToRoute = di.inject(navigateToRouteInjectionToken); + const route = di.inject(welcomeRouteInjectable); + + return () => navigateToRoute(route); + }, +}); + +export default navigateToWelcomeInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/welcome/welcome-route-config.injectable.ts b/packages/core/src/common/front-end-routing/routes/welcome/welcome-route-config.injectable.ts new file mode 100644 index 0000000000..d46b816c50 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/welcome/welcome-route-config.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "../../../vars/application-information-token"; + +const welcomeRouteConfigInjectable = getInjectable({ + id: "welcome-route-config", + + instantiate: (di) => di.inject(applicationInformationToken).config.welcomeRoute, +}); + +export default welcomeRouteConfigInjectable; diff --git a/packages/core/src/common/front-end-routing/routes/welcome/welcome-route.injectable.ts b/packages/core/src/common/front-end-routing/routes/welcome/welcome-route.injectable.ts new file mode 100644 index 0000000000..839a7446c1 --- /dev/null +++ b/packages/core/src/common/front-end-routing/routes/welcome/welcome-route.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import welcomeRouteConfigInjectable from "./welcome-route-config.injectable"; +import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; + +const welcomeRouteInjectable = getInjectable({ + id: "welcome-route", + + instantiate: (di) => { + const welcomeRoute = di.inject(welcomeRouteConfigInjectable); + + return { + path: welcomeRoute, + clusterFrame: false, + isEnabled: computed(() => true), + }; + }, + + injectionToken: frontEndRouteInjectionToken, +}); + +export default welcomeRouteInjectable; diff --git a/packages/core/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts b/packages/core/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts new file mode 100644 index 0000000000..acd1d4401d --- /dev/null +++ b/packages/core/src/common/front-end-routing/verify-that-all-routes-have-route-component.test.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../renderer/getDiForUnitTesting"; +import { routeSpecificComponentInjectionToken } from "../../renderer/routes/route-specific-component-injection-token"; +import { frontEndRouteInjectionToken } from "./front-end-route-injection-token"; +import { filter, map } from "lodash/fp"; +import clusterStoreInjectable from "../cluster-store/cluster-store.injectable"; +import type { ClusterStore } from "../cluster-store/cluster-store"; +import { pipeline } from "@ogre-tools/fp"; + +describe("verify-that-all-routes-have-component", () => { + it("verify that routes have route component", () => { + const rendererDi = getDiForUnitTesting({ doGeneralOverrides: true }); + + rendererDi.override(clusterStoreInjectable, () => ({ + getById: () => null, + } as unknown as ClusterStore)); + + const routes = rendererDi.injectMany(frontEndRouteInjectionToken); + const routeComponents = rendererDi.injectMany( + routeSpecificComponentInjectionToken, + ); + + const routesMissingComponent = pipeline( + routes, + + map( + (currentRoute) => ({ + path: currentRoute.path, + routeComponent: routeComponents.find(({ route }) => ( + route.path === currentRoute.path + && route.clusterFrame === currentRoute.clusterFrame)), + }), + ), + + filter({ routeComponent: undefined }), + + map("path"), + ); + + expect(routesMissingComponent).toEqual([]); + }); +}); diff --git a/packages/core/src/common/fs/access-path.injectable.ts b/packages/core/src/common/fs/access-path.injectable.ts new file mode 100644 index 0000000000..0504de9d6f --- /dev/null +++ b/packages/core/src/common/fs/access-path.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +export type AccessPath = (path: string, mode?: number) => Promise; + +const accessPathInjectable = getInjectable({ + id: "access-path", + instantiate: (di): AccessPath => { + const { access } = di.inject(fsInjectable); + + return async (path, mode) => { + try { + await access(path, mode); + + return true; + } catch { + return false; + } + }; + }, +}); + +export default accessPathInjectable; diff --git a/packages/core/src/common/fs/copy.global-override-for-injectable.ts b/packages/core/src/common/fs/copy.global-override-for-injectable.ts new file mode 100644 index 0000000000..b6d899d2c4 --- /dev/null +++ b/packages/core/src/common/fs/copy.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import copyInjectable from "./copy.injectable"; + +export default getGlobalOverride(copyInjectable, () => async () => { + throw new Error("tried to copy filepaths without override"); +}); diff --git a/packages/core/src/common/fs/copy.injectable.ts b/packages/core/src/common/fs/copy.injectable.ts new file mode 100644 index 0000000000..6a64ee3751 --- /dev/null +++ b/packages/core/src/common/fs/copy.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CopyOptions } from "fs-extra"; +import fsInjectable from "./fs.injectable"; + +export type Copy = (src: string, dest: string, options?: CopyOptions | undefined) => Promise; + +const copyInjectable = getInjectable({ + id: "copy", + instantiate: (di): Copy => di.inject(fsInjectable).copy, +}); + +export default copyInjectable; diff --git a/packages/core/src/common/fs/create-read-file-stream.injectable.ts b/packages/core/src/common/fs/create-read-file-stream.injectable.ts new file mode 100644 index 0000000000..8714e1cdcd --- /dev/null +++ b/packages/core/src/common/fs/create-read-file-stream.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ReadStream } from "fs"; +import fsInjectable from "./fs.injectable"; + +export interface CreateReadStreamOptions { + mode?: number; + end?: number | undefined; + flags?: string | undefined; + encoding?: BufferEncoding | undefined; + autoClose?: boolean | undefined; + /** + * @default false + */ + emitClose?: boolean | undefined; + start?: number | undefined; + highWaterMark?: number | undefined; +} + +export type CreateReadFileStream = (filePath: string, options?: CreateReadStreamOptions) => ReadStream; + +const createReadFileStreamInjectable = getInjectable({ + id: "create-read-file-stream", + instantiate: (di): CreateReadFileStream => di.inject(fsInjectable).createReadStream, +}); + +export default createReadFileStreamInjectable; diff --git a/packages/core/src/common/fs/ensure-dir.injectable.ts b/packages/core/src/common/fs/ensure-dir.injectable.ts new file mode 100644 index 0000000000..78ec4d91dc --- /dev/null +++ b/packages/core/src/common/fs/ensure-dir.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +export type EnsureDirectory = (dirPath: string) => Promise; + +const ensureDirInjectable = getInjectable({ + id: "ensure-dir", + + // TODO: Remove usages of ensureDir from business logic. + // TODO: Read, Write, Watch etc. operations should do this internally. + instantiate: (di): EnsureDirectory => di.inject(fsInjectable).ensureDir, +}); + +export default ensureDirInjectable; diff --git a/packages/core/src/common/fs/exec-file.global-override-for-injectable.ts b/packages/core/src/common/fs/exec-file.global-override-for-injectable.ts new file mode 100644 index 0000000000..162666a130 --- /dev/null +++ b/packages/core/src/common/fs/exec-file.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverrideForFunction } from "../test-utils/get-global-override-for-function"; +import execFileInjectable from "./exec-file.injectable"; + +export default getGlobalOverrideForFunction(execFileInjectable); diff --git a/packages/core/src/common/fs/exec-file.injectable.ts b/packages/core/src/common/fs/exec-file.injectable.ts new file mode 100644 index 0000000000..f026e0db3a --- /dev/null +++ b/packages/core/src/common/fs/exec-file.injectable.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ExecFileException, ExecFileOptions } from "child_process"; +import { execFile } from "child_process"; +import type { AsyncResult } from "../utils/async-result"; + +export type ExecFileError = ExecFileException & { stderr: string }; + +export interface ExecFile { + (filePath: string): Promise>; + (filePath: string, argsOrOptions: string[] | ExecFileOptions): Promise>; + (filePath: string, args: string[], options: ExecFileOptions): Promise>; +} + +const execFileInjectable = getInjectable({ + id: "exec-file", + + instantiate: (): ExecFile => { + return (filePath: string, argsOrOptions?: string[] | ExecFileOptions, maybeOptions?: ExecFileOptions) => { + const { args, options } = (() => { + if (Array.isArray(argsOrOptions)) { + return { + args: argsOrOptions, + options: maybeOptions ?? {}, + }; + } else { + return { + args: [], + options: argsOrOptions ?? {}, + }; + } + })(); + + return new Promise((resolve) => { + execFile(filePath, args, options, (error, stdout, stderr) => { + if (error) { + resolve({ + callWasSuccessful: false, + error: Object.assign(error, { stderr }), + }); + } else { + resolve({ + callWasSuccessful: true, + response: stdout, + }); + } + }); + }); + }; + }, + + causesSideEffects: true, +}); + +export default execFileInjectable; diff --git a/packages/core/src/common/fs/extract-tar.global-override-for-injectable.ts b/packages/core/src/common/fs/extract-tar.global-override-for-injectable.ts new file mode 100644 index 0000000000..02a46c1d6b --- /dev/null +++ b/packages/core/src/common/fs/extract-tar.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import extractTarInjectable from "./extract-tar.injectable"; + +export default getGlobalOverride(extractTarInjectable, () => async () => { + throw new Error("tried to extract a tar file without override"); +}); diff --git a/packages/core/src/common/fs/extract-tar.injectable.ts b/packages/core/src/common/fs/extract-tar.injectable.ts new file mode 100644 index 0000000000..410512139e --- /dev/null +++ b/packages/core/src/common/fs/extract-tar.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ExtractOptions } from "tar"; +import { extract } from "tar"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; + +export type ExtractTar = (filePath: string, opts?: ExtractOptions) => Promise; + +const extractTarInjectable = getInjectable({ + id: "extract-tar", + instantiate: (di): ExtractTar => { + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return (filePath, opts = {}) => extract({ + file: filePath, + cwd: getDirnameOfPath(filePath), + ...opts, + }); + }, + causesSideEffects: true, +}); + +export default extractTarInjectable; diff --git a/packages/core/src/common/fs/fs.injectable.ts b/packages/core/src/common/fs/fs.injectable.ts new file mode 100644 index 0000000000..f80375095c --- /dev/null +++ b/packages/core/src/common/fs/fs.injectable.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { ReadOptions } from "fs-extra"; +import fse from "fs-extra"; + +/** + * NOTE: Add corrisponding a corrisponding override of this injecable in `src/test-utils/override-fs-with-fakes.ts` + */ +const fsInjectable = getInjectable({ + id: "fs", + instantiate: () => { + const { + promises: { + readFile, + writeFile, + readdir, + lstat, + rm, + access, + stat, + }, + ensureDir, + ensureDirSync, + readFileSync, + readJson, + writeJson, + readJsonSync, + writeFileSync, + writeJsonSync, + pathExistsSync, + pathExists, + copy, + createReadStream, + } = fse; + + return { + readFile, + readJson: readJson as (file: string, options?: ReadOptions | BufferEncoding) => Promise, + writeFile, + writeJson, + pathExists, + readdir, + readFileSync, + readJsonSync, + writeFileSync, + writeJsonSync, + pathExistsSync, + lstat, + rm, + access, + copy: copy as (src: string, dest: string, options?: fse.CopyOptions) => Promise, + ensureDir: ensureDir as (path: string, options?: number | fse.EnsureOptions ) => Promise, + ensureDirSync, + createReadStream, + stat, + }; + }, + causesSideEffects: true, +}); + +export default fsInjectable; diff --git a/packages/core/src/common/fs/lstat.global-override-for-injectable.ts b/packages/core/src/common/fs/lstat.global-override-for-injectable.ts new file mode 100644 index 0000000000..9c9f3d4933 --- /dev/null +++ b/packages/core/src/common/fs/lstat.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import lstatInjectable from "./lstat.injectable"; + +export default getGlobalOverride(lstatInjectable, () => async () => { + throw new Error("tried to lstat a filepath without override"); +}); diff --git a/packages/core/src/common/fs/lstat.injectable.ts b/packages/core/src/common/fs/lstat.injectable.ts new file mode 100644 index 0000000000..50c1d4ad12 --- /dev/null +++ b/packages/core/src/common/fs/lstat.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Stats } from "fs"; +import fsInjectable from "./fs.injectable"; + +export type LStat = (path: string) => Promise; + +const lstatInjectable = getInjectable({ + id: "lstat", + instantiate: (di): LStat => di.inject(fsInjectable).lstat, +}); + +export default lstatInjectable; diff --git a/packages/core/src/common/fs/path-exists-sync.injectable.ts b/packages/core/src/common/fs/path-exists-sync.injectable.ts new file mode 100644 index 0000000000..21bcb6d7d1 --- /dev/null +++ b/packages/core/src/common/fs/path-exists-sync.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +const pathExistsSyncInjectable = getInjectable({ + id: "path-exists-sync", + instantiate: (di) => di.inject(fsInjectable).pathExistsSync, +}); + +export default pathExistsSyncInjectable; diff --git a/packages/core/src/common/fs/path-exists.injectable.ts b/packages/core/src/common/fs/path-exists.injectable.ts new file mode 100644 index 0000000000..aee6cac52b --- /dev/null +++ b/packages/core/src/common/fs/path-exists.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +export type PathExists = (path: string) => Promise; + +const pathExistsInjectable = getInjectable({ + id: "path-exists", + instantiate: (di): PathExists => di.inject(fsInjectable).pathExists, +}); + +export default pathExistsInjectable; diff --git a/packages/core/src/common/fs/read-directory.global-override-for-injectable.ts b/packages/core/src/common/fs/read-directory.global-override-for-injectable.ts new file mode 100644 index 0000000000..57c83ceffb --- /dev/null +++ b/packages/core/src/common/fs/read-directory.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import readDirectoryInjectable from "./read-directory.injectable"; + +export default getGlobalOverride(readDirectoryInjectable, () => async () => { + throw new Error("tried to read a directory's content without override"); +}); diff --git a/packages/core/src/common/fs/read-directory.injectable.ts b/packages/core/src/common/fs/read-directory.injectable.ts new file mode 100644 index 0000000000..8ebeebe75a --- /dev/null +++ b/packages/core/src/common/fs/read-directory.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Dirent } from "fs"; +import fsInjectable from "./fs.injectable"; + +export interface ReadDirectory { + ( + path: string, + options: "buffer" | { encoding: "buffer"; withFileTypes?: false | undefined } + ): Promise; + ( + path: string, + options?: + | { encoding: BufferEncoding; withFileTypes?: false | undefined } + | BufferEncoding + ): Promise; + ( + path: string, + options?: { encoding?: BufferEncoding; withFileTypes?: false | undefined }, + ): Promise; + ( + path: string, + options: { encoding?: BufferEncoding; withFileTypes: true }, + ): Promise; +} + +const readDirectoryInjectable = getInjectable({ + id: "read-directory", + instantiate: (di): ReadDirectory => di.inject(fsInjectable).readdir, +}); + +export default readDirectoryInjectable; diff --git a/packages/core/src/common/fs/read-file-buffer-sync.injectable.ts b/packages/core/src/common/fs/read-file-buffer-sync.injectable.ts new file mode 100644 index 0000000000..98ba8e6d4b --- /dev/null +++ b/packages/core/src/common/fs/read-file-buffer-sync.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +export type ReadFileBufferSync = (filePath: string) => Buffer; + +const readFileBufferSyncInjectable = getInjectable({ + id: "read-file-buffer-sync", + instantiate: (di): ReadFileBufferSync => { + const { readFileSync } = di.inject(fsInjectable); + + return (filePath) => readFileSync(filePath); + }, +}); + +export default readFileBufferSyncInjectable; diff --git a/packages/core/src/common/fs/read-file-buffer.injectable.ts b/packages/core/src/common/fs/read-file-buffer.injectable.ts new file mode 100644 index 0000000000..f7707dc594 --- /dev/null +++ b/packages/core/src/common/fs/read-file-buffer.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +const readFileBufferInjectable = getInjectable({ + id: "read-file-buffer", + + instantiate: (di) => (filePath: string) => + di.inject(fsInjectable).readFile(filePath), +}); + +export default readFileBufferInjectable; diff --git a/packages/core/src/common/fs/read-file-sync.injectable.ts b/packages/core/src/common/fs/read-file-sync.injectable.ts new file mode 100644 index 0000000000..cdb2e4b4d2 --- /dev/null +++ b/packages/core/src/common/fs/read-file-sync.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +export type ReadFileSync = (filePath: string) => string; + +const readFileSyncInjectable = getInjectable({ + id: "read-file-sync", + instantiate: (di): ReadFileSync => { + const { readFileSync } = di.inject(fsInjectable); + + return (filePath) => readFileSync(filePath, "utf-8"); + }, +}); + +export default readFileSyncInjectable; diff --git a/packages/core/src/common/fs/read-file.injectable.ts b/packages/core/src/common/fs/read-file.injectable.ts new file mode 100644 index 0000000000..0dc539e1b1 --- /dev/null +++ b/packages/core/src/common/fs/read-file.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +export type ReadFile = (filePath: string) => Promise; + +const readFileInjectable = getInjectable({ + id: "read-file", + + instantiate: (di): ReadFile => { + const { readFile } = di.inject(fsInjectable); + + return (filePath) => readFile(filePath, "utf-8"); + }, +}); + +export default readFileInjectable; diff --git a/packages/core/src/common/fs/read-json-file.injectable.ts b/packages/core/src/common/fs/read-json-file.injectable.ts new file mode 100644 index 0000000000..d270368cf9 --- /dev/null +++ b/packages/core/src/common/fs/read-json-file.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { JsonValue } from "type-fest"; +import fsInjectable from "./fs.injectable"; + +export type ReadJson = (filePath: string) => Promise; + +const readJsonFileInjectable = getInjectable({ + id: "read-json-file", + instantiate: (di): ReadJson => di.inject(fsInjectable).readJson, +}); + +export default readJsonFileInjectable; diff --git a/packages/core/src/common/fs/read-json-sync.injectable.ts b/packages/core/src/common/fs/read-json-sync.injectable.ts new file mode 100644 index 0000000000..81a9ef478f --- /dev/null +++ b/packages/core/src/common/fs/read-json-sync.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +const readJsonSyncInjectable = getInjectable({ + id: "read-json-sync", + instantiate: (di) => di.inject(fsInjectable).readJsonSync, +}); + +export default readJsonSyncInjectable; diff --git a/packages/core/src/common/fs/read-yaml-file.injectable.ts b/packages/core/src/common/fs/read-yaml-file.injectable.ts new file mode 100644 index 0000000000..a34a69d388 --- /dev/null +++ b/packages/core/src/common/fs/read-yaml-file.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import readFileInjectable from "./read-file.injectable"; +import yaml from "js-yaml"; + +export type ReadYamlFile = (filePath: string) => Promise; + +const readYamlFileInjectable = getInjectable({ + id: "read-yaml-file", + + instantiate: (di): ReadYamlFile => { + const readFile = di.inject(readFileInjectable); + + return async (filePath: string) => { + const contents = await readFile(filePath); + + return yaml.load(contents); + }; + }, +}); + +export default readYamlFileInjectable; diff --git a/packages/core/src/common/fs/remove.global-override-for-injectable.ts b/packages/core/src/common/fs/remove.global-override-for-injectable.ts new file mode 100644 index 0000000000..4b92353344 --- /dev/null +++ b/packages/core/src/common/fs/remove.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import removePathInjectable from "./remove.injectable"; + +export default getGlobalOverride(removePathInjectable, () => async () => { + throw new Error("tried to remove path without override"); +}); diff --git a/packages/core/src/common/fs/remove.injectable.ts b/packages/core/src/common/fs/remove.injectable.ts new file mode 100644 index 0000000000..cf6f03987b --- /dev/null +++ b/packages/core/src/common/fs/remove.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +export type RemovePath = (filePath: string) => Promise; + +const removePathInjectable = getInjectable({ + id: "remove-path", + instantiate: (di): RemovePath => { + const { rm } = di.inject(fsInjectable); + + return (filePath) => rm(filePath, { force: true, recursive: true }); + }, +}); + +export default removePathInjectable; diff --git a/packages/core/src/common/fs/stat.injectable.ts b/packages/core/src/common/fs/stat.injectable.ts new file mode 100644 index 0000000000..07f2b298b1 --- /dev/null +++ b/packages/core/src/common/fs/stat.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Stats } from "fs"; +import fsInjectable from "./fs.injectable"; + +export type Stat = (path: string) => Promise; + +const statInjectable = getInjectable({ + id: "stat", + instantiate: (di): Stat => di.inject(fsInjectable).stat, +}); + +export default statInjectable; diff --git a/packages/core/src/common/fs/validate-directory.injectable.ts b/packages/core/src/common/fs/validate-directory.injectable.ts new file mode 100644 index 0000000000..efce915238 --- /dev/null +++ b/packages/core/src/common/fs/validate-directory.injectable.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AsyncResult } from "../utils/async-result"; +import { isErrnoException } from "../utils"; +import type { Stats } from "fs-extra"; +import { lowerFirst } from "lodash/fp"; +import statInjectable from "./stat.injectable"; + +export type ValidateDirectory = (path: string) => Promise>; + +function getUserReadableFileType(stats: Stats): string { + if (stats.isFile()) { + return "a file"; + } + + if (stats.isFIFO()) { + return "a pipe"; + } + + if (stats.isSocket()) { + return "a socket"; + } + + if (stats.isBlockDevice()) { + return "a block device"; + } + + if (stats.isCharacterDevice()) { + return "a character device"; + } + + return "an unknown file type"; +} + +const validateDirectoryInjectable = getInjectable({ + id: "validate-directory", + + instantiate: (di): ValidateDirectory => { + const stat = di.inject(statInjectable); + + return async (path) => { + try { + const stats = await stat(path); + + if (stats.isDirectory()) { + return { callWasSuccessful: true, response: undefined }; + } + + return { callWasSuccessful: false, error: `the provided path is ${getUserReadableFileType(stats)} and not a directory.` }; + } catch (error) { + if (!isErrnoException(error)) { + return { callWasSuccessful: false, error: "of an unknown error, please try again." }; + } + + const humanReadableErrors: Record = { + ENOENT: "the provided path does not exist.", + EACCES: "search permissions is denied for one of the directories in the prefix of the provided path.", + ELOOP: "the provided path is a sym-link which points to a chain of sym-links that is too long to resolve. Perhaps it is cyclic.", + ENAMETOOLONG: "the pathname is too long to be used.", + ENOTDIR: "a prefix of the provided path is not a directory.", + }; + + const humanReadableError = error.code + ? humanReadableErrors[error.code] + : lowerFirst(String(error)); + + return { callWasSuccessful: false, error: humanReadableError }; + } + }; + }, +}); + +export default validateDirectoryInjectable; diff --git a/packages/core/src/common/fs/watch/watch.global-override-for-injectable.ts b/packages/core/src/common/fs/watch/watch.global-override-for-injectable.ts new file mode 100644 index 0000000000..689c7150cf --- /dev/null +++ b/packages/core/src/common/fs/watch/watch.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getGlobalOverride } from "../../test-utils/get-global-override"; +import watchInjectable from "./watch.injectable"; + +export default getGlobalOverride(watchInjectable, () => () => { + throw new Error("Tried to call file system watch without explicit override"); +}); diff --git a/packages/core/src/common/fs/watch/watch.injectable.ts b/packages/core/src/common/fs/watch/watch.injectable.ts new file mode 100644 index 0000000000..50f96cdf57 --- /dev/null +++ b/packages/core/src/common/fs/watch/watch.injectable.ts @@ -0,0 +1,164 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { watch } from "chokidar"; +import type { Stats } from "fs"; +import type TypedEventEmitter from "typed-emitter"; +import type { SingleOrMany } from "../../utils"; + +export interface AlwaysStatWatcherEvents { + add: (path: string, stats: Stats) => void; + addDir: (path: string, stats: Stats) => void; + change: (path: string, stats: Stats) => void; +} + +export interface MaybeStatWatcherEvents { + add: (path: string, stats?: Stats) => void; + addDir: (path: string, stats?: Stats) => void; + change: (path: string, stats?: Stats) => void; +} + +export type WatcherEvents = BaseWatcherEvents + & ( + AlwaysStat extends true + ? AlwaysStatWatcherEvents + : MaybeStatWatcherEvents + ); + +export interface BaseWatcherEvents { + error: (error: Error) => void; + ready: () => void; + unlink: (path: string) => void; + unlinkDir: (path: string) => void; +} + +export interface Watcher extends TypedEventEmitter> { + close: () => Promise; +} + +export type WatcherOptions = { + /** + * Indicates whether the process should continue to run as long as files are being watched. If + * set to `false` when using `fsevents` to watch, no more events will be emitted after `ready`, + * even if the process continues to run. + */ + persistent?: boolean; + + /** + * ([anymatch](https://github.com/micromatch/anymatch)-compatible definition) Defines files/paths to + * be ignored. The whole relative or absolute path is tested, not just filename. If a function + * with two arguments is provided, it gets called twice per path - once with a single argument + * (the path), second time with two arguments (the path and the + * [`fs.Stats`](https://nodejs.org/api/fs.html#fs_class_fs_stats) object of that path). + */ + ignored?: SingleOrMany boolean)>; + + /** + * If set to `false` then `add`/`addDir` events are also emitted for matching paths while + * instantiating the watching as chokidar discovers these file paths (before the `ready` event). + */ + ignoreInitial?: boolean; + + /** + * When `false`, only the symlinks themselves will be watched for changes instead of following + * the link references and bubbling events through the link's path. + */ + followSymlinks?: boolean; + + /** + * The base directory from which watch `paths` are to be derived. Paths emitted with events will + * be relative to this. + */ + cwd?: string; + + /** + * If set to true then the strings passed to .watch() and .add() are treated as literal path + * names, even if they look like globs. Default: false. + */ + disableGlobbing?: boolean; + + /** + * Whether to use fs.watchFile (backed by polling), or fs.watch. If polling leads to high CPU + * utilization, consider setting this to `false`. It is typically necessary to **set this to + * `true` to successfully watch files over a network**, and it may be necessary to successfully + * watch files in other non-standard situations. Setting to `true` explicitly on OS X overrides + * the `useFsEvents` default. + */ + usePolling?: boolean; + + /** + * Whether to use the `fsevents` watching interface if available. When set to `true` explicitly + * and `fsevents` is available this supercedes the `usePolling` setting. When set to `false` on + * OS X, `usePolling: true` becomes the default. + */ + useFsEvents?: boolean; + + /** + * If set, limits how many levels of subdirectories will be traversed. + */ + depth?: number; + + /** + * Interval of file system polling. + */ + interval?: number; + + /** + * Interval of file system polling for binary files. ([see list of binary extensions](https://gi + * thub.com/sindresorhus/binary-extensions/blob/master/binary-extensions.json)) + */ + binaryInterval?: number; + + /** + * Indicates whether to watch files that don't have read permissions if possible. If watching + * fails due to `EPERM` or `EACCES` with this set to `true`, the errors will be suppressed + * silently. + */ + ignorePermissionErrors?: boolean; + + /** + * `true` if `useFsEvents` and `usePolling` are `false`). Automatically filters out artifacts + * that occur when using editors that use "atomic writes" instead of writing directly to the + * source file. If a file is re-added within 100 ms of being deleted, Chokidar emits a `change` + * event rather than `unlink` then `add`. If the default of 100 ms does not work well for you, + * you can override it by setting `atomic` to a custom value, in milliseconds. + */ + atomic?: boolean | number; + + /** + * can be set to an object in order to adjust timing params: + */ + awaitWriteFinish?: AwaitWriteFinishOptions | boolean; +} & (AlwaysStat extends true + ? { + alwaysStat: true; + } + : { + alwaysStat?: false; + } +); + +export interface AwaitWriteFinishOptions { + /** + * Amount of time in milliseconds for a file size to remain constant before emitting its event. + */ + stabilityThreshold?: number; + + /** + * File size polling interval. + */ + pollInterval?: number; +} + +export type Watch = (path: string, options?: WatcherOptions) => Watcher; + +// TODO: Introduce wrapper to allow simpler API +const watchInjectable = getInjectable({ + id: "watch", + instantiate: () => watch as Watch, + causesSideEffects: true, +}); + +export default watchInjectable; diff --git a/packages/core/src/common/fs/write-buffer-sync.injectable.ts b/packages/core/src/common/fs/write-buffer-sync.injectable.ts new file mode 100644 index 0000000000..d4d253ae66 --- /dev/null +++ b/packages/core/src/common/fs/write-buffer-sync.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; +import fsInjectable from "./fs.injectable"; + +export type WriteBufferSync = (filePath: string, contents: Buffer) => void; + +const writeBufferSyncInjectable = getInjectable({ + id: "write-buffer-sync", + instantiate: (di): WriteBufferSync => { + const { + writeFileSync, + ensureDirSync, + } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return (filePath, contents) => { + ensureDirSync(getDirnameOfPath(filePath), { + mode: 0o755, + }); + writeFileSync(filePath, contents); + }; + }, +}); + +export default writeBufferSyncInjectable; diff --git a/packages/core/src/common/fs/write-file-sync.injectable.ts b/packages/core/src/common/fs/write-file-sync.injectable.ts new file mode 100644 index 0000000000..3daccaf610 --- /dev/null +++ b/packages/core/src/common/fs/write-file-sync.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; +import fsInjectable from "./fs.injectable"; + +export type WriteFileSync = (filePath: string, contents: string) => void; + +const writeFileSyncInjectable = getInjectable({ + id: "write-file-sync", + instantiate: (di): WriteFileSync => { + const { + writeFileSync, + ensureDirSync, + } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return (filePath, contents) => { + ensureDirSync(getDirnameOfPath(filePath), { + mode: 0o755, + }); + writeFileSync(filePath, contents); + }; + }, +}); + +export default writeFileSyncInjectable; diff --git a/packages/core/src/common/fs/write-file.global-override-for-injectable.ts b/packages/core/src/common/fs/write-file.global-override-for-injectable.ts new file mode 100644 index 0000000000..c8b7ef8e45 --- /dev/null +++ b/packages/core/src/common/fs/write-file.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import writeFileInjectable from "./write-file.injectable"; + +export default getGlobalOverride(writeFileInjectable, () => async () => { + throw new Error("tried to write file without override"); +}); diff --git a/packages/core/src/common/fs/write-file.injectable.ts b/packages/core/src/common/fs/write-file.injectable.ts new file mode 100644 index 0000000000..75e07775e3 --- /dev/null +++ b/packages/core/src/common/fs/write-file.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { WriteFileOptions } from "fs-extra"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; +import fsInjectable from "./fs.injectable"; + +export type WriteFile = (filePath: string, content: string | Buffer, opts?: WriteFileOptions) => Promise; + +const writeFileInjectable = getInjectable({ + id: "write-file", + + instantiate: (di): WriteFile => { + const { writeFile, ensureDir } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return async (filePath, content, opts = {}) => { + await ensureDir(getDirnameOfPath(filePath), { + mode: 0o755, + ...opts, + }); + + const { encoding = "utf-8", ...options } = opts; + + await writeFile(filePath, content, { + encoding: encoding as BufferEncoding, + ...options, + }); + }; + }, +}); + +export default writeFileInjectable; diff --git a/packages/core/src/common/fs/write-json-file.injectable.ts b/packages/core/src/common/fs/write-json-file.injectable.ts new file mode 100644 index 0000000000..5491487849 --- /dev/null +++ b/packages/core/src/common/fs/write-json-file.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; +import fsInjectable from "./fs.injectable"; + +export type WriteJson = (filePath: string, contents: unknown) => Promise; + +const writeJsonFileInjectable = getInjectable({ + id: "write-json-file", + + instantiate: (di): WriteJson => { + const { writeJson, ensureDir } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return async (filePath, content) => { + await ensureDir(getDirnameOfPath(filePath), { mode: 0o755 }); + + await writeJson(filePath, content, { + encoding: "utf-8", + spaces: 2, + }); + }; + }, +}); + +export default writeJsonFileInjectable; diff --git a/packages/core/src/common/fs/write-json-sync.injectable.ts b/packages/core/src/common/fs/write-json-sync.injectable.ts new file mode 100644 index 0000000000..eb4abc3936 --- /dev/null +++ b/packages/core/src/common/fs/write-json-sync.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; +import fsInjectable from "./fs.injectable"; + +export type WriteJsonSync = (filePath: string, contents: unknown) => void; + +const writeJsonSyncInjectable = getInjectable({ + id: "write-json-sync", + instantiate: (di): WriteJsonSync => { + const { + writeJsonSync, + ensureDirSync, + } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return (filePath, content) => { + ensureDirSync(getDirnameOfPath(filePath), { mode: 0o755 }); + + writeJsonSync(filePath, content, { + encoding: "utf-8", + spaces: 2, + }); + }; + }, +}); + +export default writeJsonSyncInjectable; diff --git a/packages/core/src/common/get-configuration-file-model/get-configuration-file-model.global-override-for-injectable.ts b/packages/core/src/common/get-configuration-file-model/get-configuration-file-model.global-override-for-injectable.ts new file mode 100644 index 0000000000..5f51460a2b --- /dev/null +++ b/packages/core/src/common/get-configuration-file-model/get-configuration-file-model.global-override-for-injectable.ts @@ -0,0 +1,113 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import assert from "assert"; +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getConfigurationFileModelInjectable from "./get-configuration-file-model.injectable"; +import type Config from "conf"; +import readJsonSyncInjectable from "../fs/read-json-sync.injectable"; +import writeJsonSyncInjectable from "../fs/write-json-sync.injectable"; +import { get, set } from "lodash"; +import semver from "semver"; + +const MIGRATION_KEY = `__internal__.migrations.version`; + +const _isVersionInRangeFormat = (version: string) => { + return semver.clean(version) === null; +}; + +const _shouldPerformMigration = (candidateVersion: string, previousMigratedVersion: string, versionToMigrate: string) => { + if (_isVersionInRangeFormat(candidateVersion)) { + if (previousMigratedVersion !== "0.0.0" && semver.satisfies(previousMigratedVersion, candidateVersion)) { + return false; + } + + return semver.satisfies(versionToMigrate, candidateVersion); + } + + if (semver.lte(candidateVersion, previousMigratedVersion)) { + return false; + } + + if (semver.gt(candidateVersion, versionToMigrate)) { + return false; + } + + return true; +}; + +export default getGlobalOverride(getConfigurationFileModelInjectable, (di) => { + const readJsonSync = di.inject(readJsonSyncInjectable); + const writeJsonSync = di.inject(writeJsonSyncInjectable); + + return (options) => { + assert(options.cwd, "Missing options.cwd"); + assert(options.configName, "Missing options.configName"); + assert(options.projectVersion, "Missing options.projectVersion"); + + const configFilePath = path.posix.join(options.cwd, `${options.configName}.json`); + let store: object = {}; + + try { + store = readJsonSync(configFilePath); + } catch { + // ignore + } + + const config = { + get store() { + return store; + }, + path: configFilePath, + get: (key: string) => get(store, key), + set: (key: string, value: unknown) => { + let currentState: object; + + try { + currentState = readJsonSync(configFilePath); + } catch { + currentState = {}; + } + + writeJsonSync(configFilePath, { + ...currentState, + [key]: value, + }); + store = readJsonSync(configFilePath); + }, + } as Partial as Config; + + // Migrate + { + const migrations = options.migrations ?? []; + const versionToMigrate = options.projectVersion; + let previousMigratedVersion = get(store, MIGRATION_KEY) || "0.0.0"; + const newerVersions = Object.entries(migrations) + .filter(([candidateVersion]) => _shouldPerformMigration(candidateVersion, previousMigratedVersion, versionToMigrate)); + + let storeBackup = { ...store }; + + for (const [version, migration] of newerVersions) { + try { + migration(config); + set(store, MIGRATION_KEY, version); + previousMigratedVersion = version; + storeBackup = { ...store }; + } + catch (error) { + store = storeBackup; + throw new Error(`Something went wrong during the migration! Changes applied to the store until this failed migration will be restored. ${error}`); + } + } + + if (_isVersionInRangeFormat(previousMigratedVersion) || !semver.eq(previousMigratedVersion, versionToMigrate)) { + set(store, MIGRATION_KEY, versionToMigrate); + } + } + + return config; + }; +}); diff --git a/packages/core/src/common/get-configuration-file-model/get-configuration-file-model.injectable.ts b/packages/core/src/common/get-configuration-file-model/get-configuration-file-model.injectable.ts new file mode 100644 index 0000000000..e167e464ef --- /dev/null +++ b/packages/core/src/common/get-configuration-file-model/get-configuration-file-model.injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import Config from "conf"; +import type { Options as ConfOptions } from "conf/dist/source/types"; + +export type GetConfigurationFileModel = (content: ConfOptions) => Config; + +const getConfigurationFileModelInjectable = getInjectable({ + id: "get-configuration-file-model", + instantiate: (): GetConfigurationFileModel => (content) => new Config(content), + causesSideEffects: true, +}); + +export default getConfigurationFileModelInjectable; diff --git a/packages/core/src/common/helm/add-helm-repository-channel.ts b/packages/core/src/common/helm/add-helm-repository-channel.ts new file mode 100644 index 0000000000..bf5aa19367 --- /dev/null +++ b/packages/core/src/common/helm/add-helm-repository-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { HelmRepo } from "./helm-repo"; +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export type AddHelmRepositoryChannel = RequestChannel>; + +export const addHelmRepositoryChannel: AddHelmRepositoryChannel = { + id: "add-helm-repository-channel", +}; diff --git a/packages/core/src/common/helm/get-active-helm-repositories-channel.ts b/packages/core/src/common/helm/get-active-helm-repositories-channel.ts new file mode 100644 index 0000000000..26720e9a5f --- /dev/null +++ b/packages/core/src/common/helm/get-active-helm-repositories-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { HelmRepo } from "./helm-repo"; +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export type GetActiveHelmRepositoriesChannel = RequestChannel>; + +export const getActiveHelmRepositoriesChannel: GetActiveHelmRepositoriesChannel = { + id: "get-helm-active-list-repositories", +}; diff --git a/packages/core/src/common/helm/helm-repo.ts b/packages/core/src/common/helm/helm-repo.ts new file mode 100644 index 0000000000..cb4af3449b --- /dev/null +++ b/packages/core/src/common/helm/helm-repo.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface HelmRepo { + name: string; + url: string; + cacheFilePath: string; + caFile?: string; + certFile?: string; + insecureSkipTlsVerify?: boolean; + keyFile?: string; + username?: string; + password?: string; +} diff --git a/packages/core/src/common/helm/remove-helm-repository-channel.ts b/packages/core/src/common/helm/remove-helm-repository-channel.ts new file mode 100644 index 0000000000..4d479d088c --- /dev/null +++ b/packages/core/src/common/helm/remove-helm-repository-channel.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; +import type { HelmRepo } from "./helm-repo"; + +export type RemoveHelmRepositoryChannel = RequestChannel>; + +export const removeHelmRepositoryChannel: RemoveHelmRepositoryChannel = { + id: "remove-helm-repository-channel", +}; diff --git a/packages/core/src/common/hotbars/add-hotbar.injectable.ts b/packages/core/src/common/hotbars/add-hotbar.injectable.ts new file mode 100644 index 0000000000..25ee0f588a --- /dev/null +++ b/packages/core/src/common/hotbars/add-hotbar.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import hotbarStoreInjectable from "./store.injectable"; +import type { CreateHotbarData, CreateHotbarOptions } from "./types"; + +export type AddHotbar = (data: CreateHotbarData, opts?: CreateHotbarOptions) => void; + +const addHotbarInjectable = getInjectable({ + id: "add-hotbar", + instantiate: (di): AddHotbar => { + const store = di.inject(hotbarStoreInjectable); + + return (data, opts) => store.add(data, opts); + }, +}); + +export default addHotbarInjectable; diff --git a/packages/core/src/common/hotbars/migrations-token.ts b/packages/core/src/common/hotbars/migrations-token.ts new file mode 100644 index 0000000000..5441844933 --- /dev/null +++ b/packages/core/src/common/hotbars/migrations-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MigrationDeclaration } from "../base-store/migrations.injectable"; + +export const hotbarStoreMigrationInjectionToken = getInjectionToken({ + id: "hotbar-store-migration-token", +}); diff --git a/packages/core/src/common/hotbars/store.injectable.ts b/packages/core/src/common/hotbars/store.injectable.ts new file mode 100644 index 0000000000..cc15f93bf8 --- /dev/null +++ b/packages/core/src/common/hotbars/store.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import { HotbarStore } from "./store"; +import loggerInjectable from "../logger.injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import storeMigrationsInjectable from "../base-store/migrations.injectable"; +import { hotbarStoreMigrationInjectionToken } from "./migrations-token"; +import getBasenameOfPathInjectable from "../path/get-basename.injectable"; +import { baseStoreIpcChannelPrefixesInjectionToken } from "../base-store/channel-prefix"; +import { persistStateToConfigInjectionToken } from "../base-store/save-to-file"; +import { enlistMessageChannelListenerInjectionToken } from "../utils/channel/enlist-message-channel-listener-injection-token"; +import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../base-store/disable-sync"; + +const hotbarStoreInjectable = getInjectable({ + id: "hotbar-store", + + instantiate: (di) => new HotbarStore({ + catalogCatalogEntity: di.inject(catalogCatalogEntityInjectable), + logger: di.inject(loggerInjectable), + directoryForUserData: di.inject(directoryForUserDataInjectable), + getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable), + storeMigrationVersion: di.inject(storeMigrationVersionInjectable), + migrations: di.inject(storeMigrationsInjectable, hotbarStoreMigrationInjectionToken), + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken), + persistStateToConfig: di.inject(persistStateToConfigInjectionToken), + enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken), + shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), + }), +}); + +export default hotbarStoreInjectable; diff --git a/packages/core/src/common/hotbars/store.ts b/packages/core/src/common/hotbars/store.ts new file mode 100644 index 0000000000..709242f20e --- /dev/null +++ b/packages/core/src/common/hotbars/store.ts @@ -0,0 +1,351 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, comparer, observable, makeObservable, computed } from "mobx"; +import type { BaseStoreDependencies } from "../base-store/base-store"; +import { BaseStore } from "../base-store/base-store"; +import { toJS } from "../utils"; +import type { CatalogEntity } from "../catalog"; +import { broadcastMessage } from "../ipc"; +import type { Hotbar, CreateHotbarData, CreateHotbarOptions } from "./types"; +import { defaultHotbarCells, getEmptyHotbar } from "./types"; +import { hotbarTooManyItemsChannel } from "../ipc/hotbar"; +import type { GeneralEntity } from "../catalog-entities"; +import type { Logger } from "../logger"; +import assert from "assert"; + +export interface HotbarStoreModel { + hotbars: Hotbar[]; + activeHotbarId: string; +} + +interface Dependencies extends BaseStoreDependencies { + readonly catalogCatalogEntity: GeneralEntity; + readonly logger: Logger; +} + +export class HotbarStore extends BaseStore { + @observable hotbars: Hotbar[] = []; + @observable private _activeHotbarId!: string; + + constructor(protected readonly dependencies: Dependencies) { + super(dependencies, { + configName: "lens-hotbar-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, + }); + makeObservable(this); + } + + @computed get activeHotbarId() { + return this._activeHotbarId; + } + + /** + * If `hotbar` points to a known hotbar, make it active. Otherwise, ignore + * @param hotbar The hotbar instance, or the index, or its ID + */ + setActiveHotbar(hotbar: Hotbar | number | string) { + if (typeof hotbar === "number") { + if (hotbar >= 0 && hotbar < this.hotbars.length) { + this._activeHotbarId = this.hotbars[hotbar].id; + } + } else if (typeof hotbar === "string") { + if (this.findById(hotbar)) { + this._activeHotbarId = hotbar; + } + } else { + if (this.hotbars.indexOf(hotbar) >= 0) { + this._activeHotbarId = hotbar.id; + } + } + } + + private hotbarIndexById(id: string) { + return this.hotbars.findIndex((hotbar) => hotbar.id === id); + } + + private hotbarIndex(hotbar: Hotbar) { + return this.hotbars.indexOf(hotbar); + } + + @computed get activeHotbarIndex() { + return this.hotbarIndexById(this.activeHotbarId); + } + + @action + protected fromStore(data: Partial = {}) { + if (!data.hotbars || !data.hotbars.length) { + const hotbar = getEmptyHotbar("Default"); + const { + metadata: { uid, name, source }, + } = this.dependencies.catalogCatalogEntity; + const initialItem = { entity: { uid, name, source }}; + + hotbar.items[0] = initialItem; + + this.hotbars = [hotbar]; + } else { + this.hotbars = data.hotbars; + } + + this.hotbars.forEach(ensureExactHotbarItemLength); + + if (data.activeHotbarId) { + this._activeHotbarId = data.activeHotbarId; + } + + if (!this._activeHotbarId) { + this._activeHotbarId = this.hotbars[0].id; + } + } + + toJSON(): HotbarStoreModel { + return toJS({ + hotbars: this.hotbars, + activeHotbarId: this.activeHotbarId, + }); + } + + getActive(): Hotbar { + const hotbar = this.findById(this.activeHotbarId); + + assert(hotbar, "There MUST always be an active hotbar"); + + return hotbar; + } + + findByName(name: string) { + return this.hotbars.find((hotbar) => hotbar.name === name); + } + + findById(id: string) { + return this.hotbars.find((hotbar) => hotbar.id === id); + } + + @action + add(data: CreateHotbarData, { setActive = false }: CreateHotbarOptions = {}) { + const hotbar = getEmptyHotbar(data.name, data.id); + + this.hotbars.push(hotbar); + + if (setActive) { + this._activeHotbarId = hotbar.id; + } + } + + @action + setHotbarName(id: string, name: string): void { + const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); + + if (index < 0) { + return this.dependencies.logger.warn( + `[HOTBAR-STORE]: cannot setHotbarName: unknown id`, + { id }, + ); + } + + this.hotbars[index].name = name; + } + + @action + remove(hotbar: Hotbar) { + assert(this.hotbars.length >= 2, "Cannot remove the last hotbar"); + + this.hotbars = this.hotbars.filter((h) => h !== hotbar); + + if (this.activeHotbarId === hotbar.id) { + this.setActiveHotbar(0); + } + } + + @action + addToHotbar(item: CatalogEntity, cellIndex?: number) { + const hotbar = this.getActive(); + const uid = item.getId(); + const name = item.getName(); + + if (typeof uid !== "string") { + throw new TypeError("CatalogEntity's ID must be a string"); + } + + if (typeof name !== "string") { + throw new TypeError("CatalogEntity's NAME must be a string"); + } + + if (this.isAddedToActive(item)) { + return; + } + + const entity = { + uid, + name, + source: item.metadata.source, + }; + const newItem = { entity }; + + if (cellIndex === undefined) { + // Add item to empty cell + const emptyCellIndex = hotbar.items.indexOf(null); + + if (emptyCellIndex != -1) { + hotbar.items[emptyCellIndex] = newItem; + } else { + broadcastMessage(hotbarTooManyItemsChannel); + } + } else if (0 <= cellIndex && cellIndex < hotbar.items.length) { + hotbar.items[cellIndex] = newItem; + } else { + this.dependencies.logger.error( + `[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range`, + { entityId: uid, hotbarId: hotbar.id, cellIndex }, + ); + } + } + + @action + removeFromHotbar(uid: string): void { + const hotbar = this.getActive(); + const index = hotbar.items.findIndex((item) => item?.entity.uid === uid); + + if (index < 0) { + return; + } + + hotbar.items[index] = null; + } + + /** + * Remove all hotbar items that reference the `uid`. + * @param uid The `EntityId` that each hotbar item refers to + * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. + */ + @action + removeAllHotbarItems(uid: string) { + for (const hotbar of this.hotbars) { + const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); + + if (index >= 0) { + hotbar.items[index] = null; + } + } + } + + findClosestEmptyIndex(from: number, direction = 1) { + let index = from; + const hotbar = this.getActive(); + + while (hotbar.items[index] != null) { + index += direction; + } + + return index; + } + + @action + restackItems(from: number, to: number): void { + const { items } = this.getActive(); + const source = items[from]; + const moveDown = from < to; + + if ( + from < 0 || + to < 0 || + from >= items.length || + to >= items.length || + isNaN(from) || + isNaN(to) + ) { + throw new Error("Invalid 'from' or 'to' arguments"); + } + + if (from == to) { + return; + } + + items.splice(from, 1, null); + + if (items[to] == null) { + items.splice(to, 1, source); + } else { + // Move cells up or down to closes empty cell + items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1); + items.splice(to, 0, source); + } + } + + switchToPrevious() { + let index = this.activeHotbarIndex - 1; + + if (index < 0) { + index = this.hotbars.length - 1; + } + + this.setActiveHotbar(index); + } + + switchToNext() { + let index = this.activeHotbarIndex + 1; + + if (index >= this.hotbars.length) { + index = 0; + } + + this.setActiveHotbar(index); + } + + /** + * Checks if entity already pinned to the active hotbar + */ + isAddedToActive(entity: CatalogEntity | null | undefined): boolean { + if (!entity) { + return false; + } + + const indexInActiveHotbar = this.getActive().items.findIndex(item => item?.entity.uid === entity.getId()); + + return indexInActiveHotbar >= 0; + } + + getDisplayLabel(hotbar: Hotbar): string { + return `${this.getDisplayIndex(hotbar)}: ${hotbar.name}`; + } + + getDisplayIndex(hotbar: Hotbar): string { + const index = this.hotbarIndex(hotbar); + + if (index < 0) { + return "??"; + } + + return `${index + 1}`; + } +} + +/** + * This function ensures that there are always exactly `defaultHotbarCells` + * worth of items in the hotbar. + * @param hotbar The hotbar to modify + */ +function ensureExactHotbarItemLength(hotbar: Hotbar) { + // if there are not enough items + while (hotbar.items.length < defaultHotbarCells) { + hotbar.items.push(null); + } + + // if for some reason the hotbar was overfilled before, remove as many entries + // as needed, but prefer empty slots and items at the end first. + while (hotbar.items.length > defaultHotbarCells) { + const lastNull = hotbar.items.lastIndexOf(null); + + if (lastNull >= 0) { + hotbar.items.splice(lastNull, 1); + } else { + hotbar.items.length = defaultHotbarCells; + } + } +} diff --git a/packages/core/src/common/hotbars/types.ts b/packages/core/src/common/hotbars/types.ts new file mode 100644 index 0000000000..6370fe136d --- /dev/null +++ b/packages/core/src/common/hotbars/types.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import * as uuid from "uuid"; +import type { Tuple } from "../utils"; +import { tuple } from "../utils"; + +export interface HotbarItem { + entity: { + uid: string; + name: string; + source?: string; + }; + params?: { + [key: string]: string; + }; +} + +export type Hotbar = Required; + +export interface CreateHotbarData { + id?: string; + name: string; + items?: Tuple; +} + +export interface CreateHotbarOptions { + setActive?: boolean; +} + +export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard + +export function getEmptyHotbar(name: string, id: string = uuid.v4()): Hotbar { + return { + id, + items: tuple.filled(defaultHotbarCells, null), + name, + }; +} diff --git a/packages/core/src/common/initializable-state/create.test.ts b/packages/core/src/common/initializable-state/create.test.ts new file mode 100644 index 0000000000..38980b0a41 --- /dev/null +++ b/packages/core/src/common/initializable-state/create.test.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { DiContainer, Injectable } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import type { InitializableState } from "./create"; +import { createInitializableState } from "./create"; + +describe("InitializableState tests", () => { + let di: DiContainer; + + beforeEach(() => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + }); + + describe("when created", () => { + let stateInjectable: Injectable, unknown, void>; + let initMock: AsyncFnMock<() => number>; + + beforeEach(() => { + initMock = asyncFn(); + stateInjectable = createInitializableState({ + id: "my-state", + init: initMock, + }); + + runInAction(() => { + di.register(stateInjectable); + }); + }); + + describe("when injected", () => { + let state: InitializableState; + + beforeEach(() => { + state = di.inject(stateInjectable); + }); + + it("when get is called, throw", () => { + expect(() => state.get()).toThrowError("InitializableState(my-state) has not been initialized yet"); + }); + + describe("when init is called", () => { + beforeEach(() => { + state.init(); + }); + + it("should call provided initialization function", () => { + expect(initMock).toBeCalled(); + }); + + it("when get is called, throw", () => { + expect(() => state.get()).toThrowError("InitializableState(my-state) has not finished initializing"); + }); + + describe("when initialization resolves", () => { + beforeEach(async () => { + await initMock.resolve(42); + }); + + it("when get is called, returns value", () => { + expect(state.get()).toBe(42); + }); + + it("when init is called again, throws", async () => { + await expect(() => state.init()).rejects.toThrow("Cannot initialize InitializableState(my-state) more than once"); + }); + }); + }); + }); + }); +}); diff --git a/packages/core/src/common/initializable-state/create.ts b/packages/core/src/common/initializable-state/create.ts new file mode 100644 index 0000000000..829de57d94 --- /dev/null +++ b/packages/core/src/common/initializable-state/create.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainerForInjection, Injectable, InjectionToken } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; + +export interface CreateInitializableStateArgs { + id: string; + init: (di: DiContainerForInjection) => Promise | T; + injectionToken?: InjectionToken, void>; +} + +export interface InitializableState { + get: () => T; + init: () => Promise; +} + +type InitializableStateValue = + | { set: false } + | { set: true; value: T } ; + +export function createInitializableState(args: CreateInitializableStateArgs): Injectable, unknown, void> { + const { id, init, injectionToken } = args; + + return getInjectable({ + id, + instantiate: (di) => { + let box: InitializableStateValue = { + set: false, + }; + let initCalled = false; + + return { + init: async () => { + if (initCalled) { + throw new Error(`Cannot initialize InitializableState(${id}) more than once`); + } + + initCalled = true; + box = { + set: true, + value: await init(di), + }; + }, + get: () => { + if (!initCalled) { + throw new Error(`InitializableState(${id}) has not been initialized yet`); + } + + if (box.set === false) { + throw new Error(`InitializableState(${id}) has not finished initializing`); + } + + return box.value; + }, + }; + }, + injectionToken, + }); +} diff --git a/packages/core/src/common/ipc/broadcast-message.global-override-for-injectable.ts b/packages/core/src/common/ipc/broadcast-message.global-override-for-injectable.ts new file mode 100644 index 0000000000..e455b30cdc --- /dev/null +++ b/packages/core/src/common/ipc/broadcast-message.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverrideForFunction } from "../test-utils/get-global-override-for-function"; +import broadcastMessageInjectable from "./broadcast-message.injectable"; + +export default getGlobalOverrideForFunction(broadcastMessageInjectable); diff --git a/packages/core/src/common/ipc/broadcast-message.injectable.ts b/packages/core/src/common/ipc/broadcast-message.injectable.ts new file mode 100644 index 0000000000..41db749d65 --- /dev/null +++ b/packages/core/src/common/ipc/broadcast-message.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { broadcastMessage } from "./ipc"; + +export type BroadcastMessage = (channel: string, ...args: any[]) => Promise; + +const broadcastMessageInjectable = getInjectable({ + id: "broadcast-message", + instantiate: (): BroadcastMessage => broadcastMessage, + causesSideEffects: true, +}); + +export default broadcastMessageInjectable; diff --git a/packages/core/src/common/ipc/catalog.ts b/packages/core/src/common/ipc/catalog.ts new file mode 100644 index 0000000000..3d316d211c --- /dev/null +++ b/packages/core/src/common/ipc/catalog.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * This is used to activate a specific entity in the renderer main frame + */ +export const catalogEntityRunListener = "catalog-entity:run"; + +/** + * This is broadcast on whenever there is an update to any catalog item + */ +export const catalogItemsChannel = "catalog:items"; + +/** + * This can be sent from renderer to main to initialize a broadcast of ITEMS + */ +export const catalogInitChannel = "catalog:init"; diff --git a/packages/core/src/common/ipc/cluster.ts b/packages/core/src/common/ipc/cluster.ts new file mode 100644 index 0000000000..53061abf60 --- /dev/null +++ b/packages/core/src/common/ipc/cluster.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const clusterActivateHandler = "cluster:activate"; +export const clusterSetFrameIdHandler = "cluster:set-frame-id"; +export const clusterVisibilityHandler = "cluster:visibility"; +export const clusterDisconnectHandler = "cluster:disconnect"; +export const clusterStates = "cluster:states"; + +/** + * This channel is broadcast on whenever the cluster fails to list namespaces + * during a refresh and no `accessibleNamespaces` have been set. + */ +export const clusterListNamespaceForbiddenChannel = "cluster:list-namespace-forbidden"; + +export type ListNamespaceForbiddenArgs = [clusterId: string]; + +export function isListNamespaceForbiddenArgs(args: unknown[]): args is ListNamespaceForbiddenArgs { + return args.length === 1 && typeof args[0] === "string"; +} diff --git a/packages/core/src/common/ipc/extension-handling.ts b/packages/core/src/common/ipc/extension-handling.ts new file mode 100644 index 0000000000..1474a6d18d --- /dev/null +++ b/packages/core/src/common/ipc/extension-handling.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const extensionDiscoveryStateChannel = "extension-discovery:state"; +export const bundledExtensionsLoaded = "extension-loader:bundled-extensions-loaded"; +export const extensionLoaderFromMainChannel = "extension-loader:main:state"; +export const extensionLoaderFromRendererChannel = "extension-loader:renderer:state"; diff --git a/packages/core/src/common/ipc/hotbar.ts b/packages/core/src/common/ipc/hotbar.ts new file mode 100644 index 0000000000..3617ebd6c5 --- /dev/null +++ b/packages/core/src/common/ipc/hotbar.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const hotbarTooManyItemsChannel = "hotbar:too-many-items"; diff --git a/packages/core/src/common/ipc/index.ts b/packages/core/src/common/ipc/index.ts new file mode 100644 index 0000000000..aa2a538560 --- /dev/null +++ b/packages/core/src/common/ipc/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./ipc"; +export * from "./invalid-kubeconfig"; diff --git a/packages/core/src/common/ipc/invalid-kubeconfig/index.ts b/packages/core/src/common/ipc/invalid-kubeconfig/index.ts new file mode 100644 index 0000000000..04ebcb9a4a --- /dev/null +++ b/packages/core/src/common/ipc/invalid-kubeconfig/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const InvalidKubeconfigChannel = "invalid-kubeconfig"; + +export type InvalidKubeConfigArgs = [clusterId: string]; diff --git a/packages/core/src/common/ipc/ipc.ts b/packages/core/src/common/ipc/ipc.ts new file mode 100644 index 0000000000..f11227d17e --- /dev/null +++ b/packages/core/src/common/ipc/ipc.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Inter-process communications (main <-> renderer) +// https://www.electronjs.org/docs/api/ipc-main +// https://www.electronjs.org/docs/api/ipc-renderer + +import { ipcMain, ipcRenderer, webContents } from "electron"; +import { toJS } from "../utils/toJS"; +import type { ClusterFrameInfo } from "../cluster-frames"; +import { clusterFrameMap } from "../cluster-frames"; +import type { Disposer } from "../utils"; +import ipcMainInjectable from "../../main/utils/channel/ipc-main/ipc-main.injectable"; +import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import ipcRendererInjectable from "../../renderer/utils/channel/ipc-renderer.injectable"; +import loggerInjectable from "../logger.injectable"; + +export const broadcastMainChannel = "ipc:broadcast-main"; + +export function ipcMainHandle(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any) { + const di = getLegacyGlobalDiForExtensionApi(); + + const ipcMain = di.inject(ipcMainInjectable); + + ipcMain.handle(channel, async (event, ...args) => { + return sanitizePayload(await listener(event, ...args)); + }); +} + +function getSubFrames(): ClusterFrameInfo[] { + return Array.from(clusterFrameMap.values()); +} + +export async function broadcastMessage(channel: string, ...args: any[]): Promise { + if (ipcRenderer) { + return ipcRenderer.invoke(broadcastMainChannel, channel, ...args.map(sanitizePayload)); + } + + if (!webContents) { + return; + } + + const di = getLegacyGlobalDiForExtensionApi(); + const logger = di.inject(loggerInjectable); + + ipcMain.listeners(channel).forEach((func) => func({ + processId: undefined, frameId: undefined, sender: undefined, senderFrame: undefined, + }, ...args)); + + const subFrames = getSubFrames(); + const views = webContents.getAllWebContents(); + + if (!views || !Array.isArray(views) || views.length === 0) return; + + args = args.map(sanitizePayload); + + for (const view of views) { + let viewType = "unknown"; + + // There will be a uncaught exception if the view is destroyed. + try { + viewType = view.getType(); + } catch { + // We can ignore the view destroyed exception as viewType is only used for logging. + } + + // Send message to views. + try { + logger.silly(`[IPC]: broadcasting "${channel}" to ${viewType}=${view.id}`, { args }); + view.send(channel, ...args); + } catch (error) { + logger.error(`[IPC]: failed to send IPC message "${channel}" to view "${viewType}=${view.id}"`, { error }); + } + + // Send message to subFrames of views. + for (const frameInfo of subFrames) { + logger.silly(`[IPC]: broadcasting "${channel}" to subframe "frameInfo.processId"=${frameInfo.processId} "frameInfo.frameId"=${frameInfo.frameId}`, { args }); + + try { + view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); + } catch (error) { + logger.error(`[IPC]: failed to send IPC message "${channel}" to view "${viewType}=${view.id}"'s subframe "frameInfo.processId"=${frameInfo.processId} "frameInfo.frameId"=${frameInfo.frameId}`, { error: String(error) }); + } + } + } +} + +export function ipcMainOn(channel: string, listener: (event: Electron.IpcMainEvent, ...args: any[]) => any): Disposer { + const di = getLegacyGlobalDiForExtensionApi(); + + const ipcMain = di.inject(ipcMainInjectable); + + ipcMain.on(channel, listener); + + return () => ipcMain.off(channel, listener); +} + +export function ipcRendererOn(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer { + const di = getLegacyGlobalDiForExtensionApi(); + + const ipcRenderer = di.inject(ipcRendererInjectable); + + ipcRenderer.on(channel, listener); + + return () => ipcRenderer.off(channel, listener); +} + +/** + * Sanitizing data for IPC-messaging before send. + * Removes possible observable values to avoid exceptions like "can't clone object". + */ +function sanitizePayload(data: any): T { + return toJS(data); +} diff --git a/packages/core/src/common/ipc/window.ts b/packages/core/src/common/ipc/window.ts new file mode 100644 index 0000000000..23a83177c6 --- /dev/null +++ b/packages/core/src/common/ipc/window.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const windowActionHandleChannel = "window:window-action"; +export const windowOpenAppMenuAsContextMenuChannel = "window:open-app-context-menu"; +export const windowLocationChangedChannel = "window:location-changed"; + +/** + * The supported actions on the current window. The argument for `windowActionHandleChannel` + */ +export enum WindowAction { + /** + * Request that the current window goes back one step of browser history + */ + GO_BACK = "back", + + /** + * Request that the current window goes forward one step of browser history + */ + GO_FORWARD = "forward", + + /** + * Request that the current window is minimized + */ + MINIMIZE = "minimize", + + /** + * Request that the current window is maximized if it isn't, or unmaximized + * if it is + */ + TOGGLE_MAXIMIZE = "toggle-maximize", + + /** + * Request that the current window is closed + */ + CLOSE = "close", +} diff --git a/packages/core/src/common/item.store.ts b/packages/core/src/common/item.store.ts new file mode 100644 index 0000000000..805137e95c --- /dev/null +++ b/packages/core/src/common/item.store.ts @@ -0,0 +1,215 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import orderBy from "lodash/orderBy"; +import { autoBind } from "./utils"; +import { action, computed, observable, when, makeObservable } from "mobx"; + +export interface ItemObject { + getId(): string; + getName(): string; +} + +export abstract class ItemStore { + protected defaultSorting = (item: Item) => item.getName(); + + @observable failedLoading = false; + @observable isLoading = false; + @observable isLoaded = false; + @observable items = observable.array([], { deep: false }); + @observable selectedItemsIds = observable.set(); + + constructor() { + makeObservable(this); + autoBind(this); + } + + @computed get selectedItems(): Item[] { + return this.pickOnlySelected(this.items); + } + + public pickOnlySelected(items: Item[]): Item[] { + return items.filter(item => this.selectedItemsIds.has(item.getId())); + } + + public getItems(): Item[] { + return Array.from(this.items); + } + + public getTotalCount(): number { + return this.items.length; + } + + getByName(name: string): Item | undefined { + return this.items.find(item => item.getName() === name); + } + + getIndexById(id: string): number { + return this.items.findIndex(item => item.getId() === id); + } + + /** + * Return `items` sorted by the given ordering functions. If two elements of + * `items` are sorted to the same "index" then the next sorting function is used + * to determine where to place them relative to each other. Once the `sorting` + * functions have been all exhausted then the order is unchanged (ie a stable sort). + * @param items the items to be sorted (default: the current items in this store) + * @param sorting list of functions to determine sort order (default: sorting by name) + * @param order whether to sort from least to greatest (`"asc"` (default)) or vice-versa (`"desc"`) + */ + @action + protected sortItems(items: Item[] = this.items, sorting: ((item: Item) => any)[] = [this.defaultSorting], order?: "asc" | "desc"): Item[] { + return orderBy(items, sorting, order); + } + + protected async createItem(...args: any[]): Promise; + @action + protected async createItem(request: () => Promise) { + const newItem = await request(); + const item = this.items.find(item => item.getId() === newItem.getId()); + + if (item) { + return item; + } else { + const items = this.sortItems([...this.items, newItem]); + + this.items.replace(items); + + return newItem; + } + } + + protected async loadItems(...args: any[]): Promise; + /** + * Load items to this.items + * @param request Function to return the items to be loaded. + * @param sortItems If true, items will be sorted. + * @param concurrency If true, concurrent loadItems() calls will all be executed. If false, only the first. + * @returns + */ + @action + protected async loadItems(request: () => Promise, sortItems = true, concurrency = false) { + if (this.isLoading) { + await when(() => !this.isLoading); + + // If concurrency for loading is disabled, return instead of loading + if (!concurrency) { + return; + } + } + this.isLoading = true; + + try { + let items = await request(); + + if (sortItems) items = this.sortItems(items); + this.items.replace(items); + this.isLoaded = true; + } finally { + this.isLoading = false; + } + } + + @action + protected async loadItem(request: () => Promise, sortItems = true) { + const item = await Promise.resolve(request()).catch(() => null); + + if (item) { + const existingItem = this.items.find(el => el.getId() === item.getId()); + + if (existingItem) { + const index = this.items.findIndex(item => item === existingItem); + + this.items.splice(index, 1, item); + } else { + let items = [...this.items, item]; + + if (sortItems) items = this.sortItems(items); + this.items.replace(items); + } + } + + return item; + } + + @action + protected async updateItem(item: Item, request: () => Promise) { + const updatedItem = await request(); + const index = this.items.findIndex(i => i.getId() === item.getId()); + + this.items.splice(index, 1, updatedItem); + + return updatedItem; + } + + @action + protected async removeItem(item: Item, request: () => Promise) { + await request(); + this.items.remove(item); + this.selectedItemsIds.delete(item.getId()); + } + + isSelected(item: Item) { + return this.selectedItemsIds.has(item.getId()); + } + + @action + select(item: Item) { + this.selectedItemsIds.add(item.getId()); + } + + @action + unselect(item: Item) { + this.selectedItemsIds.delete(item.getId()); + } + + @action + toggleSelection(item: Item) { + if (this.isSelected(item)) { + this.unselect(item); + } else { + this.select(item); + } + } + + @action + toggleSelectionAll(visibleItems: Item[] = this.items) { + const allSelected = visibleItems.every(this.isSelected); + + if (allSelected) { + visibleItems.forEach(this.unselect); + } else { + visibleItems.forEach(this.select); + } + } + + isSelectedAll(visibleItems: Item[] = this.items) { + if (!visibleItems.length) return false; + + return visibleItems.every(this.isSelected); + } + + @action + resetSelection() { + this.selectedItemsIds.clear(); + } + + @action + reset() { + this.resetSelection(); + this.items.clear(); + this.selectedItemsIds.clear(); + this.isLoaded = false; + this.isLoading = false; + } + + async removeSelectedItems?(): Promise; + + async removeItems?(items: Item[]): Promise; + + * [Symbol.iterator]() { + yield* this.items; + } +} diff --git a/packages/core/src/common/k8s-api/__tests__/api-manager.test.ts b/packages/core/src/common/k8s-api/__tests__/api-manager.test.ts new file mode 100644 index 0000000000..b83f52c3f0 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/api-manager.test.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainer } from "@ogre-tools/injectable"; +import createClusterInjectable from "../../../main/create-cluster/create-cluster.injectable"; +import clusterFrameContextForNamespacedResourcesInjectable from "../../../renderer/cluster-frame-context/for-namespaced-resources.injectable"; +import hostedClusterInjectable from "../../../renderer/cluster-frame-context/hosted-cluster.injectable"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import directoryForKubeConfigsInjectable from "../../app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import directoryForUserDataInjectable from "../../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import loggerInjectable from "../../logger.injectable"; +import type { ApiManager } from "../api-manager"; +import apiManagerInjectable from "../api-manager/manager.injectable"; +import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; +import { KubeObjectStore } from "../kube-object.store"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +class TestApi extends KubeApi { + protected async checkPreferredVersion() { + return; + } +} + +class TestStore extends KubeObjectStore { + +} + +describe("ApiManager", () => { + let apiManager: ApiManager; + let di: DiContainer; + + beforeEach(() => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-user-store-path"); + di.override(directoryForKubeConfigsInjectable, () => "/some-kube-configs"); + di.override(storesAndApisCanBeCreatedInjectable, () => true); + + const createCluster = di.inject(createClusterInjectable); + + di.override(hostedClusterInjectable, () => createCluster({ + contextName: "some-context-name", + id: "some-cluster-id", + kubeConfigPath: "/some-path-to-a-kubeconfig", + }, { + clusterServerUrl: "https://localhost:8080", + })); + + apiManager = di.inject(apiManagerInjectable); + }); + + describe("registerApi", () => { + it("re-register store if apiBase changed", async () => { + const apiBase = "apis/v1/foo"; + const fallbackApiBase = "/apis/extensions/v1beta1/foo"; + const kubeApi = new TestApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }, { + objectConstructor: KubeObject, + apiBase, + kind: "foo", + fallbackApiBases: [fallbackApiBase], + checkPreferredVersion: true, + }); + const kubeStore = new TestStore({ + context: di.inject(clusterFrameContextForNamespacedResourcesInjectable), + logger: di.inject(loggerInjectable), + }, kubeApi); + + apiManager.registerApi(apiBase, kubeApi); + + // Define to use test api for ingress store + Object.defineProperty(kubeStore, "api", { value: kubeApi }); + apiManager.registerStore(kubeStore, [kubeApi]); + + // Test that store is returned with original apiBase + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); + + // Change apiBase similar as checkPreferredVersion does + Object.defineProperty(kubeApi, "apiBase", { value: fallbackApiBase }); + apiManager.registerApi(fallbackApiBase, kubeApi); + + // Test that store is returned with new apiBase + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/crd.test.ts b/packages/core/src/common/k8s-api/__tests__/crd.test.ts new file mode 100644 index 0000000000..e1538dfbe6 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/crd.test.ts @@ -0,0 +1,177 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CustomResourceDefinitionSpec } from "../endpoints"; +import { CustomResourceDefinition } from "../endpoints"; + +describe("Crds", () => { + describe("getVersion()", () => { + it("should throw if none of the versions are served", () => { + const crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", + }, + spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", + versions: [ + { + name: "123", + served: false, + storage: false, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, + }); + + expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo"); + }); + + it("should get the version that is both served and stored", () => { + const crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", + }, + spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", + versions: [ + { + name: "123", + served: true, + storage: true, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, + }); + + expect(crd.getVersion()).toBe("123"); + }); + + it("should get the version that is only stored", () => { + const crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", + }, + spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", + versions: [ + { + name: "123", + served: false, + storage: true, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, + }); + + expect(crd.getVersion()).toBe("123"); + }); + + it("should get the version that is both served and stored even with version field", () => { + const crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", + }, + spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", + version: "abc", + versions: [ + { + name: "123", + served: true, + storage: true, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, + }); + + expect(crd.getVersion()).toBe("123"); + }); + + it("should get the version name from the version field, ignoring versions on v1beta", () => { + const crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1beta1", + kind: "CustomResourceDefinition", + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", + }, + spec: { + version: "abc", + versions: [ + { + name: "foobar", + served: true, + storage: true, + }, + ], + } as CustomResourceDefinitionSpec, + }); + + expect(crd.getVersion()).toBe("abc"); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/deployment.api.test.ts b/packages/core/src/common/k8s-api/__tests__/deployment.api.test.ts new file mode 100644 index 0000000000..60ada4e4d1 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/deployment.api.test.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import apiKubeInjectable from "../../../renderer/k8s/api-kube.injectable"; +import type { DeploymentApi } from "../endpoints/deployment.api"; +import deploymentApiInjectable from "../endpoints/deployment.api.injectable"; +import type { KubeJsonApi } from "../kube-json-api"; + +describe("DeploymentApi", () => { + let deploymentApi: DeploymentApi; + let kubeJsonApi: jest.Mocked; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(storesAndApisCanBeCreatedInjectable, () => true); + kubeJsonApi = { + getResponse: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + del: jest.fn(), + } as never; + di.override(apiKubeInjectable, () => kubeJsonApi); + + deploymentApi = di.inject(deploymentApiInjectable); + }); + + describe("scale", () => { + it("requests Kubernetes API with PATCH verb and correct amount of replicas", () => { + deploymentApi.scale({ namespace: "default", name: "deployment-1" }, 5); + + expect(kubeJsonApi.patch).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/deployments/deployment-1/scale", { + data: { + spec: { + replicas: 5, + }, + }, + }, + { + headers: { + "content-type": "application/merge-patch+json", + }, + }); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/endpoint.api.test.ts b/packages/core/src/common/k8s-api/__tests__/endpoint.api.test.ts new file mode 100644 index 0000000000..666c5df63a --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/endpoint.api.test.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { formatEndpointSubset } from "../endpoints"; + +describe("endpoint tests", () => { + describe("EndpointSubset", () => { + it("formatEndpointSubset should be addresses X ports", () => { + const formatted = formatEndpointSubset({ + addresses: [{ + ip: "1.1.1.1", + }, { + ip: "1.1.1.2", + }], + notReadyAddresses: [], + ports: [{ + port: 81, + }, { + port: 82, + }], + }); + + expect(formatted).toBe("1.1.1.1:81, 1.1.1.1:82, 1.1.1.2:81, 1.1.1.2:82"); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/helm-charts.api.test.ts b/packages/core/src/common/k8s-api/__tests__/helm-charts.api.test.ts new file mode 100644 index 0000000000..e7a4cb9439 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/helm-charts.api.test.ts @@ -0,0 +1,275 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { anyObject } from "jest-mock-extended"; +import { HelmChart } from "../endpoints/helm-charts.api"; + +describe("HelmChart tests", () => { + describe("HelmChart.create() tests", () => { + it("should throw on non-object input", () => { + expect(() => HelmChart.create("" as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(1 as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(false as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create([] as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(Symbol() as never)).toThrowError('"value" must be of type object'); + }); + + it("should throw on missing fields", () => { + expect(() => HelmChart.create({} as never)).toThrowError('"apiVersion" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + } as never)).toThrowError('"name" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + } as never)).toThrowError('"version" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + version: "!", + } as never)).toThrowError('"repo" is required'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + version: "!", + repo: "!", + } as never)).toThrowError('"created" is required'); + }); + + it("should throw on fields being wrong type", () => { + expect(() => HelmChart.create({ + apiVersion: 1, + name: "!", + version: "!", + repo: "!", + created: "!", + digest: "!", + } as never)).toThrowError('"apiVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: 1, + version: "!", + repo: "!", + created: "!", + digest: "!", + } as never)).toThrowError('"name" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "!", + name: "!", + version: "!", + repo: "!", + created: "!", + digest: 1, + } as never)).toThrowError('"digest" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "", + version: 1, + repo: "!", + created: "!", + digest: "!", + } as never)).toThrowError('"version" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: 1, + created: "!", + digest: "!", + } as never)).toThrowError('"repo" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + created: 1, + digest: "a", + } as never)).toThrowError('"created" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + created: "!", + digest: 1, + } as never)).toThrowError('"digest" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + kubeVersion: 1, + } as never)).toThrowError('"kubeVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + description: 1, + } as never)).toThrowError('"description" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + home: 1, + } as never)).toThrowError('"home" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + engine: 1, + } as never)).toThrowError('"engine" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + icon: 1, + } as never)).toThrowError('"icon" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + appVersion: 1, + } as never)).toThrowError('"appVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + tillerVersion: 1, + } as never)).toThrowError('"tillerVersion" must be a string'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + deprecated: 1, + } as never)).toThrowError('"deprecated" must be a boolean'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + keywords: 1, + } as never)).toThrowError('"keywords" must be an array'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + sources: 1, + } as never)).toThrowError('"sources" must be an array'); + expect(() => HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + maintainers: 1, + } as never)).toThrowError('"maintainers" must be an array'); + }); + + it("should filter non-string keywords", () => { + const chart = HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + keywords: [1, "a", false, {}, "b"] as never, + }); + + expect(chart?.keywords).toStrictEqual(["a", "b"]); + }); + + it("should filter non-string sources", () => { + const chart = HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + sources: [1, "a", false, {}, "b"] as never, + }); + + expect(chart?.sources).toStrictEqual(["a", "b"]); + }); + + it("should filter invalid maintainers", () => { + const chart = HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + maintainers: [{ + name: "a", + email: "b", + url: "c", + }] as never, + }); + + expect(chart?.maintainers).toStrictEqual([{ + name: "a", + email: "b", + url: "c", + }]); + }); + + it("should warn on unknown fields", () => { + const { warn } = console; + const warnFn = console.warn = jest.fn(); + + HelmChart.create({ + apiVersion: "1", + name: "1", + version: "1", + repo: "1", + digest: "1", + created: "!", + maintainers: [{ + name: "a", + email: "b", + url: "c", + }] as never, + "asdjhajksdhadjks": 1, + } as never); + + expect(warnFn).toHaveBeenCalledWith("HelmChart data has unexpected fields", { + original: anyObject(), + unknownFields: ["asdjhajksdhadjks"], + }); + console.warn = warn; + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/ingress.api.test.ts b/packages/core/src/common/k8s-api/__tests__/ingress.api.test.ts new file mode 100644 index 0000000000..acb2a59a68 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/ingress.api.test.ts @@ -0,0 +1,164 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { computeRuleDeclarations, Ingress } from "../endpoints"; + +describe("Ingress", () => { + it("given no loadbalancer ingresses in status property, loadbalancers should be an empty array", () => { + const ingress = new Ingress({ + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "foo", + resourceVersion: "1", + uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", + }, + status: { + loadBalancer: {}, + }, + }); + + expect(ingress.getLoadBalancers()).toEqual([]); + }); + + it("given loadbalancer ingresses in status property, loadbalancers should be flat array of ip adresses and hostnames", () => { + const ingress = new Ingress({ + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "foo", + resourceVersion: "1", + uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", + }, + status: { + loadBalancer: { + ingress: [{ + ip: "10.0.0.27", + }, + { + hostname: "localhost", + }], + }, + }, + }); + + expect(ingress.getLoadBalancers()).toEqual(["10.0.0.27", "localhost"]); + }); +}); + +describe("computeRuleDeclarations", () => { + it("given no tls field, should format links as http://", () => { + const ingress = new Ingress({ + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "foo", + resourceVersion: "1", + uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", + }, + }); + + const result = computeRuleDeclarations(ingress, { + host: "foo.bar", + http: { + paths: [{ + pathType: "Exact", + backend: { + service: { + name: "my-service", + port: { + number: 8080, + }, + }, + }, + }], + }, + }); + + expect(result[0].url).toBe("http://foo.bar/"); + }); + + it("given no tls entries, should format links as http://", () => { + const ingress = new Ingress({ + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "foo", + resourceVersion: "1", + uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", + }, + }); + + ingress.spec = { + tls: [], + }; + + const result = computeRuleDeclarations(ingress, { + host: "foo.bar", + http: { + paths: [{ + pathType: "Exact", + backend: { + service: { + name: "my-service", + port: { + number: 8080, + }, + }, + }, + }], + }, + }); + + expect(result[0].url).toBe("http://foo.bar/"); + }); + + it("given some tls entries, should format links as https://", () => { + const ingress = new Ingress({ + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "foo", + resourceVersion: "1", + uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", + }, + }); + + ingress.spec = { + tls: [{ + secretName: "my-secret", + }], + }; + + const result = computeRuleDeclarations(ingress, { + host: "foo.bar", + http: { + paths: [{ + pathType: "Exact", + backend: { + service: { + name: "my-service", + port: { + number: 8080, + }, + }, + }, + }], + }, + }); + + expect(result[0].url).toBe("https://foo.bar/"); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/kube-api-parse.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-api-parse.test.ts new file mode 100644 index 0000000000..547f78adef --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/kube-api-parse.test.ts @@ -0,0 +1,131 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +jest.mock("../kube-object"); +jest.mock("../kube-api"); +jest.mock("../api-manager", () => ({ + apiManager() { + return { + registerStore: jest.fn(), + }; + }, +})); + +import type { IKubeApiParsed } from "../kube-api-parse"; +import { parseKubeApi } from "../kube-api-parse"; + +/** + * [, ] + */ +type KubeApiParseTestData = [string, IKubeApiParsed]; + +const tests: KubeApiParseTestData[] = [ + ["/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com", { + apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions", + apiPrefix: "/apis", + apiGroup: "apiextensions.k8s.io", + apiVersion: "v1beta1", + apiVersionWithGroup: "apiextensions.k8s.io/v1beta1", + namespace: undefined, + resource: "customresourcedefinitions", + name: "prometheuses.monitoring.coreos.com", + }], + ["/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", { + apiBase: "/api/v1/pods", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + namespace: "kube-system", + resource: "pods", + name: "coredns-6955765f44-v8p27", + }], + ["/apis/stable.example.com/foo1/crontabs", { + apiBase: "/apis/stable.example.com/foo1/crontabs", + apiPrefix: "/apis", + apiGroup: "stable.example.com", + apiVersion: "foo1", + apiVersionWithGroup: "stable.example.com/foo1", + resource: "crontabs", + name: undefined, + namespace: undefined, + }], + ["/apis/cluster.k8s.io/v1alpha1/clusters", { + apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters", + apiPrefix: "/apis", + apiGroup: "cluster.k8s.io", + apiVersion: "v1alpha1", + apiVersionWithGroup: "cluster.k8s.io/v1alpha1", + resource: "clusters", + name: undefined, + namespace: undefined, + }], + ["/api/v1/namespaces", { + apiBase: "/api/v1/namespaces", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "namespaces", + name: undefined, + namespace: undefined, + }], + ["/api/v1/secrets", { + apiBase: "/api/v1/secrets", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "secrets", + name: undefined, + namespace: undefined, + }], + ["/api/v1/nodes/minikube", { + apiBase: "/api/v1/nodes", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "nodes", + name: "minikube", + namespace: undefined, + }], + ["/api/foo-bar/nodes/minikube", { + apiBase: "/api/foo-bar/nodes", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "foo-bar", + apiVersionWithGroup: "foo-bar", + resource: "nodes", + name: "minikube", + namespace: undefined, + }], + ["/api/v1/namespaces/kube-public", { + apiBase: "/api/v1/namespaces", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "namespaces", + name: "kube-public", + namespace: undefined, + }], +]; + +const throwtests = [ + undefined, + "", + "ajklsmh", +]; + +describe("parseApi unit tests", () => { + it.each(tests)("testing %j", (url, expected) => { + expect(parseKubeApi(url)).toStrictEqual(expected); + }); + + it.each(throwtests)("testing %j should throw", (url) => { + expect(() => parseKubeApi(url as never)).toThrowError("invalid apiPath"); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts new file mode 100644 index 0000000000..94c6e0cd9c --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts @@ -0,0 +1,712 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApiManager } from "../api-manager"; +import type { IngressApi } from "../endpoints"; +import { Ingress } from "../endpoints"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import apiManagerInjectable from "../api-manager/manager.injectable"; +import type { Fetch } from "../../fetch/fetch.injectable"; +import fetchInjectable from "../../fetch/fetch.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { flushPromises } from "../../test-utils/flush-promises"; +import createKubeJsonApiInjectable from "../create-kube-json-api.injectable"; +import setupAutoRegistrationInjectable from "../../../renderer/before-frame-starts/runnables/setup-auto-registration.injectable"; +import { createMockResponseFromString } from "../../../test-utils/mock-responses"; +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import directoryForUserDataInjectable from "../../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import createClusterInjectable from "../../../main/create-cluster/create-cluster.injectable"; +import hostedClusterInjectable from "../../../renderer/cluster-frame-context/hosted-cluster.injectable"; +import directoryForKubeConfigsInjectable from "../../app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import apiKubeInjectable from "../../../renderer/k8s/api-kube.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import ingressApiInjectable from "../endpoints/ingress.api.injectable"; + +describe("KubeApi", () => { + let di: DiContainer; + let registerApiSpy: jest.SpiedFunction; + let fetchMock: AsyncFnMock; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); + + di.override(directoryForUserDataInjectable, () => "/some-user-store-path"); + di.override(directoryForKubeConfigsInjectable, () => "/some-kube-configs"); + di.override(storesAndApisCanBeCreatedInjectable, () => true); + + const createCluster = di.inject(createClusterInjectable); + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + di.override(hostedClusterInjectable, () => createCluster({ + contextName: "some-context-name", + id: "some-cluster-id", + kubeConfigPath: "/some-path-to-a-kubeconfig", + }, { + clusterServerUrl: "https://localhost:8080", + })); + + di.override(apiKubeInjectable, () => createKubeJsonApi({ + serverAddress: `http://127.0.0.1:9999`, + apiBase: "/api-kube", + })); + + registerApiSpy = jest.spyOn(di.inject(apiManagerInjectable), "registerApi"); + + const setupAutoRegistration = di.inject(setupAutoRegistrationInjectable); + + setupAutoRegistration.run(); + }); + + describe("on first call to IngressApi.get()", () => { + let ingressApi: IngressApi; + let getCall: Promise; + + beforeEach(async () => { + ingressApi = di.inject(ingressApiInjectable); + getCall = ingressApi.get({ + name: "foo", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("requests version list from the api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the version list from the api group resolves", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "networking.k8s.io", + versions: [ + { + groupVersion: "networking.k8s.io/v1", + version: "v1", + }, + { + groupVersion: "networking.k8s.io/v1beta1", + version: "v1beta1", + }, + ], + preferredVersion: { + groupVersion: "networking.k8s.io/v1", + version: "v1", + }, + })), + ); + }); + + it("requests resources from the versioned api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1", + apiPrefix: "/apis", + apiGroup: "networking.k8s.io", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + + describe("when resource request fufills with no resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1", JSON.stringify({ + resources: [], + })), + ); + }); + + it("requests resources from the second versioned api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1beta1", + apiPrefix: "/apis", + apiGroup: "networking.k8s.io", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + }); + }); + + describe("when the version list from the api group resolves with no versions", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/networking.k8s.io"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/networking.k8s.io", JSON.stringify({ + "metadata": {}, + "status": "Failure", + "message": "the server could not find the requested resource", + "reason": "NotFound", + "details": { + "causes": [ + { + "reason": "UnexpectedServerResponse", + "message": "404 page not found", + }, + ], + }, + "code": 404, + }), 404), + ); + }); + + it("requests the resources from the base api url from the fallback api", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when resource request fufills with a resource", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "extensions", + versions: [ + { + groupVersion: "extensions/v1beta1", + version: "v1beta1", + }, + ], + preferredVersion: { + groupVersion: "extensions/v1beta1", + version: "v1beta1", + }, + })), + ); + }); + + it("requests resource versions from the versioned api group from the fallback apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the preferred version request resolves to v1beta1", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions", JSON.stringify({ + resources: [{ + name: "ingresses", + }], + })), + ); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + it("sets fields in the api instance", () => { + expect(ingressApi).toEqual(expect.objectContaining({ + apiVersionPreferred: "v1beta1", + apiPrefix: "/apis", + apiGroup: "extensions", + })); + }); + + it("registers the api with the changes info", () => { + expect(registerApiSpy).toBeCalledWith(ingressApi); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + + describe("when the request resolves with data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo", JSON.stringify({ + apiVersion: "v1beta1", + kind: "Ingress", + metadata: { + name: "foo", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + })), + ); + result = await getCall; + }); + + it("results in the get call resolving to an instance", () => { + expect(result).toBeInstanceOf(Ingress); + }); + + describe("on the second call to IngressApi.get()", () => { + let getCall: Promise; + + beforeEach(async () => { + getCall = ingressApi.get({ + name: "foo1", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("makes the request to get the resource", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with no data", () => { + let result: Ingress | null; + + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1/namespaces/default/ingresses/foo1", JSON.stringify({})), + ); + result = await getCall; + }); + + it("results in the get call resolving to null", () => { + expect(result).toBeNull(); + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/kube-api.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-api.test.ts new file mode 100644 index 0000000000..7bce8a3b7c --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/kube-api.test.ts @@ -0,0 +1,1227 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeApiWatchCallback } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import { PassThrough } from "stream"; +import type { DeploymentApi, NamespaceApi } from "../endpoints"; +import { Deployment, Pod, PodApi } from "../endpoints"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import type { Fetch } from "../../fetch/fetch.injectable"; +import fetchInjectable from "../../fetch/fetch.injectable"; +import type { CreateKubeApiForRemoteCluster } from "../create-kube-api-for-remote-cluster.injectable"; +import createKubeApiForRemoteClusterInjectable from "../create-kube-api-for-remote-cluster.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { flushPromises } from "../../test-utils/flush-promises"; +import createKubeJsonApiInjectable from "../create-kube-json-api.injectable"; +import type { IKubeWatchEvent } from "../kube-watch-event"; +import type { KubeJsonApiDataFor } from "../kube-object"; +import AbortController from "abort-controller"; +import setupAutoRegistrationInjectable from "../../../renderer/before-frame-starts/runnables/setup-auto-registration.injectable"; +import { createMockResponseFromStream, createMockResponseFromString } from "../../../test-utils/mock-responses"; +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import directoryForUserDataInjectable from "../../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import createClusterInjectable from "../../../main/create-cluster/create-cluster.injectable"; +import hostedClusterInjectable from "../../../renderer/cluster-frame-context/hosted-cluster.injectable"; +import directoryForKubeConfigsInjectable from "../../app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import apiKubeInjectable from "../../../renderer/k8s/api-kube.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import deploymentApiInjectable from "../endpoints/deployment.api.injectable"; +import podApiInjectable from "../endpoints/pod.api.injectable"; +import namespaceApiInjectable from "../endpoints/namespace.api.injectable"; + +// NOTE: this is fine because we are testing something that only exported +// eslint-disable-next-line no-restricted-imports +import { PodsApi } from "../../../extensions/common-api/k8s-api"; + +describe("createKubeApiForRemoteCluster", () => { + let createKubeApiForRemoteCluster: CreateKubeApiForRemoteCluster; + let fetchMock: AsyncFnMock; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-user-store-path"); + di.override(directoryForKubeConfigsInjectable, () => "/some-kube-configs"); + di.override(storesAndApisCanBeCreatedInjectable, () => true); + + const createCluster = di.inject(createClusterInjectable); + + di.override(hostedClusterInjectable, () => createCluster({ + contextName: "some-context-name", + id: "some-cluster-id", + kubeConfigPath: "/some-path-to-a-kubeconfig", + }, { + clusterServerUrl: "https://localhost:8080", + })); + + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); + + createKubeApiForRemoteCluster = di.inject(createKubeApiForRemoteClusterInjectable); + }); + + it("builds api client for KubeObject", async () => { + const api = createKubeApiForRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, Pod); + + expect(api).toBeInstanceOf(KubeApi); + }); + + describe("when building for remote cluster with specific constructor", () => { + let api: PodApi; + + beforeEach(() => { + api = createKubeApiForRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, Pod, PodsApi); + }); + + it("uses the constructor", () => { + expect(api).toBeInstanceOf(PodApi); + }); + + describe("when calling list without namespace", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list(); + + // This is required because of how JS promises work + await flushPromises(); + }); + + it("should request pods from default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:6443/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["https://127.0.0.1:6443/api/v1/pods"], + createMockResponseFromString("https://127.0.0.1:6443/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata:{ + resourceVersion: "452899", + }, + items: [], + })), + ); + }); + + it("resolves the list call", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + }); +}); + +describe("KubeApi", () => { + let fetchMock: AsyncFnMock; + let di: DiContainer; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-user-store-path"); + di.override(directoryForKubeConfigsInjectable, () => "/some-kube-configs"); + di.override(storesAndApisCanBeCreatedInjectable, () => true); + + fetchMock = asyncFn(); + di.override(fetchInjectable, () => fetchMock); + + const createCluster = di.inject(createClusterInjectable); + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + + di.override(hostedClusterInjectable, () => createCluster({ + contextName: "some-context-name", + id: "some-cluster-id", + kubeConfigPath: "/some-path-to-a-kubeconfig", + }, { + clusterServerUrl: "https://localhost:8080", + })); + + di.override(apiKubeInjectable, () => createKubeJsonApi({ + serverAddress: `http://127.0.0.1:9999`, + apiBase: "/api-kube", + })); + + const setupAutoRegistration = di.inject(setupAutoRegistrationInjectable); + + setupAutoRegistration.run(); + }); + + describe("patching deployments", () => { + let api: DeploymentApi; + + beforeEach(() => { + api = di.inject(deploymentApiInjectable); + }); + + describe("when patching a resource without providing a strategy", () => { + let patchRequest: Promise; + + beforeEach(async () => { + patchRequest = api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("requests a patch using strategic merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + method: "patch", + body: JSON.stringify({ spec: { replicas: 2 }}), + }, + ]); + }); + + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + spec: { + replicas: 2, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); + }); + }); + + describe("when patching a resource using json patch", () => { + let patchRequest: Promise; + + beforeEach(async () => { + patchRequest = api.patch({ name: "test", namespace: "default" }, [ + { op: "replace", path: "/spec/replicas", value: 2 }, + ], "json"); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("requests a patch using json merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/json-patch+json", + }, + method: "patch", + body: JSON.stringify([ + { op: "replace", path: "/spec/replicas", value: 2 }, + ]), + }, + ]); + }); + + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + }, + spec: { + replicas: 2, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); + }); + }); + + describe("when patching a resource using merge patch", () => { + let patchRequest: Promise; + + beforeEach(async () => { + patchRequest = api.patch( + { name: "test", namespace: "default" }, + { metadata: { annotations: { provisioned: "True" }}}, + "merge", + ); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("requests a patch using json merge", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", + { + headers: { + "content-type": "application/merge-patch+json", + }, + method: "patch", + body: JSON.stringify({ metadata: { annotations: { provisioned: "True" }}}), + }, + ]); + }); + + describe("when the patch request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/apis/apps/v1/namespaces/default/deployments/test", JSON.stringify({ + apiVersion: "v1", + kind: "Deployment", + metadata: { + name: "test", + namespace: "default", + resourceVersion: "1", + uid: "12345", + annotations: { + provisioned: "True", + }, + }, + })), + ); + }); + + it("resolves the patch call", async () => { + expect(await patchRequest).toBeInstanceOf(Deployment); + }); + }); + }); + }); + + describe("deleting pods (namespace scoped resource)", () => { + let api: PodApi; + + beforeEach(() => { + api = di.inject(podApiInjectable); + }); + + describe("when deleting by just name", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting pod in default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and empty namespace", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting pod in default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and namespace", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "test" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting pod in given namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/test/pods/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + }); + + describe("deleting namespaces (cluser scoped resource)", () => { + let api: NamespaceApi; + + beforeEach(() => { + api = di.inject(namespaceApiInjectable); + }); + + describe("when deleting by just name", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting Namespace without namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and empty namespace", () => { + let deleteRequest: Promise; + + beforeEach(async () => { + deleteRequest = api.delete({ name: "foo", namespace: "" }); + + // This is required for how JS promises work + await flushPromises(); + }); + + it("requests deleting Namespace without namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", + { + headers: { + "content-type": "application/json", + }, + method: "delete", + }, + ]); + }); + + describe("when request resolves", () => { + beforeEach(async () => { + fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/foo?propagationPolicy=Background", "{}"), + ); + }); + + it("resolves the call", async () => { + expect(await deleteRequest).toBeDefined(); + }); + }); + }); + + describe("when deleting by name and namespace", () => { + it("rejects request", () => { + expect(api.delete({ name: "foo", namespace: "test" })).rejects.toBeDefined(); + }); + }); + }); + + describe("watching pods", () => { + let api: PodApi; + let stream: PassThrough; + + beforeEach(() => { + api = di.inject(podApiInjectable); + stream = new PassThrough(); + }); + + afterEach(() => { + stream.end(); + stream.destroy(); + }); + + describe("when watching in a namespace", () => { + let stopWatch: () => void; + let callback: jest.MockedFunction; + + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ + namespace: "kube-system", + callback, + }); + + await flushPromises(); + }); + + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600"; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", stream), + ); + }); + + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); + }); + }); + + describe("when watching in a namespace with an abort controller provided", () => { + let callback: jest.MockedFunction; + let abortController: AbortController; + + beforeEach(async () => { + callback = jest.fn(); + abortController = new AbortController(); + api.watch({ + namespace: "kube-system", + callback, + abortController, + }); + + await flushPromises(); + }); + + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600"; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=600", stream), + ); + }); + + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch via the controller", () => { + beforeEach(() => { + abortController.abort(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); + }); + }); + + describe("when watching in a namespace with a timeout", () => { + let stopWatch: () => void; + let callback: jest.MockedFunction; + + beforeEach(async () => { + callback = jest.fn(); + stopWatch = api.watch({ + namespace: "kube-system", + callback, + timeout: 60, + }); + + await flushPromises(); + }); + + it("requests the watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with a stream", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ([url, init]) => { + const isMatch = url === "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60"; + + if (isMatch) { + init?.signal?.addEventListener("abort", () => { + stream.destroy(); + }); + } + + return isMatch; + }, + createMockResponseFromStream("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", stream), + ); + }); + + describe("when some data comes back on the stream", () => { + beforeEach(() => { + stream.emit("data", `${JSON.stringify({ + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + uid: "123456", + }, + }, + } as IKubeWatchEvent>)}\n`); + }); + + it("calls the callback with the data", () => { + expect(callback).toBeCalledWith( + { + type: "ADDED", + object: { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "foobar", + namespace: "kube-system", + resourceVersion: "1", + selfLink: "/api/v1/namespaces/kube-system/pods/foobar", + uid: "123456", + }, + }, + }, + null, + ); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + + describe("when the watch ends", () => { + beforeEach(() => { + stream.end(); + }); + + it("requests a new watch", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=&timeoutSeconds=60", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when stopping the watch", () => { + beforeEach(() => { + stopWatch(); + }); + + it("closes the stream", () => { + expect(stream.destroyed).toBe(true); + }); + }); + }); + }); + }); + }); + }); + + describe("creating pods", () => { + let api: PodApi; + + beforeEach(() => { + api = di.inject(podApiInjectable); + }); + + describe("when creating a pod", () => { + let createRequest: Promise; + + beforeEach(async () => { + createRequest = api.create({ + name: "foobar", + namespace: "default", + }, { + metadata: { + labels: { + foo: "bar", + }, + }, + spec: { + containers: [ + { + name: "web", + image: "nginx", + ports: [ + { + name: "web", + containerPort: 80, + protocol: "TCP", + }, + ], + }, + ], + }, + }); + + // This is required because of how JS promises work + await flushPromises(); + }); + + it("should request to create a pod with full descriptor", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", + { + headers: { + "content-type": "application/json", + }, + method: "post", + body: JSON.stringify({ + metadata: { + labels: { + foo: "bar", + }, + name: "foobar", + namespace: "default", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + kind: "Pod", + apiVersion: "v1", + }), + }, + ]); + }); + + describe("when request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + name: "foobar", + namespace: "default", + labels: { + foo: "bar", + }, + resourceVersion: "1", + uid: "123456798", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + })), + ); + }); + + it("call should resolve in a Pod instance", async () => { + expect(await createRequest).toBeInstanceOf(Pod); + }); + }); + }); + }); + + describe("updating pods", () => { + let api: PodApi; + + beforeEach(() => { + api = di.inject(podApiInjectable); + }); + + describe("when updating a pod", () => { + let updateRequest: Promise; + + beforeEach(async () => { + updateRequest = api.update({ + name: "foobar", + namespace: "default", + }, { + kind: "Pod", + apiVersion: "v1", + metadata: { + labels: { + foo: "bar", + }, + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + }); + + await flushPromises(); + }); + + it("should request that the pod is updated", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar", + { + headers: { + "content-type": "application/json", + }, + method: "put", + body: JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + labels: { + foo: "bar", + }, + name: "foobar", + namespace: "default", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + }), + }, + ]); + }); + + describe("when the request resolves with data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foobar", JSON.stringify({ + kind: "Pod", + apiVersion: "v1", + metadata: { + name: "foobar", + namespace: "default", + labels: { + foo: "bar", + }, + resourceVersion: "1", + uid: "123456798", + }, + spec: { + containers: [{ + name: "web", + image: "nginx", + ports: [{ + name: "web", + containerPort: 80, + protocol: "TCP", + }], + }], + }, + })), + ); + }); + + it("the call should resolve to a Pod", async () => { + expect(await updateRequest).toBeInstanceOf(Pod); + }); + }); + }); + }); + + describe("listing pods", () => { + let api: PodApi; + + beforeEach(() => { + api = di.inject(podApiInjectable); + }); + + describe("when listing pods with no descriptor", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list(); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace=''", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "", + }); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace='default'", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "default", + }); + + await flushPromises(); + }); + + it("should request that the pods from just the default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods"], + createMockResponseFromString("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/kube-object.store.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-object.store.test.ts new file mode 100644 index 0000000000..424cffba23 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/kube-object.store.test.ts @@ -0,0 +1,156 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { noop } from "../../utils"; +import type { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; +import type { KubeObjectStoreLoadingParams } from "../kube-object.store"; +import { KubeObjectStore } from "../kube-object.store"; + +class FakeKubeObjectStore extends KubeObjectStore { + constructor(private readonly _loadItems: (params: KubeObjectStoreLoadingParams) => KubeObject[], api: Partial>) { + super({ + context: { + allNamespaces: [], + contextNamespaces: [], + hasSelectedAll: false, + isGlobalWatchEnabled: () => true, + isLoadingAll: () => true, + }, + logger: { + debug: noop, + error: noop, + info: noop, + silly: noop, + warn: noop, + }, + }, api as KubeApi); + } + + async loadItems(params: KubeObjectStoreLoadingParams) { + return this._loadItems(params); + } +} + +describe("KubeObjectStore", () => { + it("should remove an object from the list of items after it is not returned from listing the same namespace again", async () => { + const loadItems = jest.fn(); + const obj = new KubeObject({ + apiVersion: "v1", + kind: "Foo", + metadata: { + name: "some-obj-name", + resourceVersion: "1", + uid: "some-uid", + namespace: "default", + selfLink: "/some/self/link", + }, + }); + const store = new FakeKubeObjectStore(loadItems, { + isNamespaced: true, + }); + + loadItems.mockImplementationOnce(() => [obj]); + + await store.loadAll({ + namespaces: ["default"], + }); + + expect(store.items).toContain(obj); + + loadItems.mockImplementationOnce(() => []); + + await store.loadAll({ + namespaces: ["default"], + }); + + expect(store.items).not.toContain(obj); + }); + + it("should not remove an object that is not returned, if it is in a different namespace", async () => { + const loadItems = jest.fn(); + const objInDefaultNamespace = new KubeObject({ + apiVersion: "v1", + kind: "Foo", + metadata: { + name: "some-obj-name", + resourceVersion: "1", + uid: "some-uid", + namespace: "default", + selfLink: "/some/self/link", + }, + }); + const objNotInDefaultNamespace = new KubeObject({ + apiVersion: "v1", + kind: "Foo", + metadata: { + name: "some-obj-name", + resourceVersion: "1", + uid: "some-uid", + namespace: "not-default", + selfLink: "/some/self/link", + }, + }); + const store = new FakeKubeObjectStore(loadItems, { + isNamespaced: true, + }); + + loadItems.mockImplementationOnce(() => [objInDefaultNamespace]); + + await store.loadAll({ + namespaces: ["default"], + }); + + expect(store.items).toContain(objInDefaultNamespace); + + loadItems.mockImplementationOnce(() => [objNotInDefaultNamespace]); + + await store.loadAll({ + namespaces: ["not-default"], + }); + + expect(store.items).toContain(objInDefaultNamespace); + }); + + it("should remove all objects not returned if the api is cluster-scoped", async () => { + const loadItems = jest.fn(); + const clusterScopedObject1 = new KubeObject({ + apiVersion: "v1", + kind: "Foo", + metadata: { + name: "some-obj-name", + resourceVersion: "1", + uid: "some-uid", + selfLink: "/some/self/link", + }, + }); + const clusterScopedObject2 = new KubeObject({ + apiVersion: "v1", + kind: "Foo", + metadata: { + name: "some-obj-name", + resourceVersion: "1", + uid: "some-uid", + namespace: "not-default", + selfLink: "/some/self/link", + }, + }); + const store = new FakeKubeObjectStore(loadItems, { + isNamespaced: false, + }); + + loadItems.mockImplementationOnce(() => [clusterScopedObject1]); + + await store.loadAll({}); + + expect(store.items).toContain(clusterScopedObject1); + + loadItems.mockImplementationOnce(() => [clusterScopedObject2]); + + await store.loadAll({}); + + expect(store.items).not.toContain(clusterScopedObject1); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/kube-object.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-object.test.ts new file mode 100644 index 0000000000..4d5865c5db --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/kube-object.test.ts @@ -0,0 +1,219 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObject } from "../kube-object"; + +describe("KubeObject", () => { + describe("isJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" }}], + ["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" }}], + ["metadata", { kind: "", apiVersion: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: "" }}], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" }}], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" }}], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: {}}], + ["apiVersion", { apiVersion: 1, kind: "", metadata: {}}], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1 }}], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1 }}], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1 }}], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 }}], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 }}], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 }}], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 }}], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 }}], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] }}], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {}}}], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 }}], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 }}}], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 }}], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 }}}], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiData (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" }}}; + + expect(KubeObject.isJsonApiData(valid)).toBe(true); + }); + }); + + describe("isPartialJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept {}", () => { + expect(KubeObject.isPartialJsonApiData({})).toBe(true); + }); + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" }}], + ["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" }}], + ["metadata", { kind: "", apiVersion: "" }], + ]; + + it.each(tests)("should not reject with missing top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(true); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" }}], + ["apiVersion", { apiVersion: 1, kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" }}], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1, name: "", resourceVersion: "", selfLink: "" }}], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1, resourceVersion: "", selfLink: "" }}], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1, selfLink: "" }}], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 }}], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 }}], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 }}], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 }}], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 }}], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] }}], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {}}}], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 }}], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 }}}], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 }}], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 }}}], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid Partial (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" }}}; + + expect(KubeObject.isPartialJsonApiData(valid)).toBe(true); + }); + }); + + describe("isJsonApiDataList", () => { + function isAny(val: unknown): val is any { + return true; + } + + function isNotAny(val: unknown): val is any { + return false; + } + + function isBoolean(val: unknown): val is boolean { + return typeof val === "boolean"; + } + + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", items: [], metadata: { resourceVersion: "", selfLink: "" }}], + ["apiVersion", { kind: "", items: [], metadata: { resourceVersion: "", selfLink: "" }}], + ["metadata", { kind: "", items: [], apiVersion: "" }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" }}], + ["apiVersion", { kind: "", items: [], apiVersion: 1, metadata: { resourceVersion: "", selfLink: "" }}], + ["metadata", { kind: "", items: [], apiVersion: "", metadata: 1 }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: 1, selfLink: "" }}], + ["metadata.selfLink", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: 1 }}], + ["items", { kind: "", items: 1, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" }}], + ["items", { kind: "", items: "", apiVersion: "", metadata: { resourceVersion: "", selfLink: "" }}], + ["items", { kind: "", items: {}, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" }}], + ["items[0]", { kind: "", items: [""], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" }}], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isNotAny)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiDataList (ignoring other fields)", () => { + const valid = { kind: "", items: [false], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" }}; + + expect(KubeObject.isJsonApiDataList(valid, isBoolean)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/node.test.ts b/packages/core/src/common/k8s-api/__tests__/node.test.ts new file mode 100644 index 0000000000..53ffc59d79 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/node.test.ts @@ -0,0 +1,192 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { Node } from "../endpoints"; + +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +describe("Node tests", () => { + describe("isMasterNode()", () => { + it("given a master node labelled before kubernetes 1.20, should return true", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/master": "NoSchedule", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(true); + }); + + it("given a master node labelled after kubernetes 1.20, should return true", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/control-plane": "NoSchedule", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(true); + }); + + it("given a non master node, should return false", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: {}, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(false); + }); + }); + + describe("getRoleLabels()", () => { + it("should return empty string if labels is not present", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.getRoleLabels()).toBe(""); + }); + + it("should return empty string if labels is empty object", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: {}, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.getRoleLabels()).toBe(""); + }); + + it("should return rest of keys with substring node-role.kubernetes.io/", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/foobar": "bat", + "hellonode-role.kubernetes.io/foobar1": "bat", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.getRoleLabels()).toBe("foobar, foobar1"); + }); + + it("should return rest of keys with substring node-role.kubernetes.io/ after last /", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/foobar": "bat", + "hellonode-role.kubernetes.io//////foobar1": "bat", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.getRoleLabels()).toBe("foobar, foobar1"); + }); + + it("should return value of label kubernetes.io/role if present", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "kubernetes.io/role": "master", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.getRoleLabels()).toBe("master"); + }); + + it("should return value of label node.kubernetes.io/role if present", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node.kubernetes.io/role": "master", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.getRoleLabels()).toBe("master"); + }); + + it("all sources should be joined together", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "aksjhdkjahsdnode-role.kubernetes.io/foobar": "bat", + "kubernetes.io/role": "master", + "node.kubernetes.io/role": "master-v2-max", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.getRoleLabels()).toBe("foobar, master, master-v2-max"); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/pods.api.test.ts b/packages/core/src/common/k8s-api/__tests__/pods.api.test.ts new file mode 100644 index 0000000000..9b01dff73b --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/pods.api.test.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { Pod } from "../endpoints"; + +describe("Pod tests", () => { + it("getAllContainers() should never throw", () => { + const pod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + namespace: "default", + selfLink: "/api/v1/pods/default/foobar", + }, + }); + + expect(pod.getAllContainers()).toStrictEqual([]); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/pods.test.ts b/packages/core/src/common/k8s-api/__tests__/pods.test.ts new file mode 100644 index 0000000000..3ed979face --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/pods.test.ts @@ -0,0 +1,318 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import assert from "assert"; +import type { Container, PodContainerStatus } from "../endpoints"; +import { Pod } from "../endpoints"; + +interface GetDummyPodOptions { + running?: number; + dead?: number; + initRunning?: number; + initDead?: number; +} + +function getDummyPod(rawOpts: GetDummyPodOptions = {}): Pod { + const { + running = 0, + dead = 0, + initDead = 0, + initRunning = 0, + } = rawOpts; + + const containers: Container[] = []; + const initContainers: Container[] = []; + const containerStatuses: PodContainerStatus[] = []; + const initContainerStatuses: PodContainerStatus[] = []; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + uid: "1", + name: "test", + resourceVersion: "v1", + namespace: "default", + selfLink: "/api/v1/pods/default/test", + }, + spec: { + containers, + initContainers, + serviceAccount: "dummy", + serviceAccountName: "dummy", + }, + status: { + phase: "Running", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses, + initContainerStatuses, + }, + }); + + for (let i = 0; i < running; i += 1) { + const name = `container_running_${i}`; + + containers.push({ + image: "dummy", + imagePullPolicy: "Always", + name, + }); + containerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: true, + restartCount: i, + state: { + running: { + startedAt: "before", + }, + }, + }); + } + + for (let i = 0; i < dead; i += 1) { + const name = `container_dead_${i}`; + + containers.push({ + image: "dummy", + imagePullPolicy: "Always", + name, + }); + containerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: false, + restartCount: i, + state: { + terminated: { + startedAt: "before", + exitCode: i+1, + finishedAt: "later", + reason: `reason_${i}`, + }, + }, + }); + } + + for (let i = 0; i < initRunning; i += 1) { + const name = `container_init-running_${i}`; + + initContainers.push({ + image: "dummy", + imagePullPolicy: "Always", + name, + }); + initContainerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: true, + restartCount: i, + state: { + running: { + startedAt: "before", + }, + }, + }); + } + + for (let i = 0; i < initDead; i += 1) { + const name = `container_init-dead_${i}`; + + initContainers.push({ + image: "dummy", + imagePullPolicy: "Always", + name, + }); + initContainerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: false, + restartCount: i, + state: { + terminated: { + startedAt: "before", + exitCode: i+1, + finishedAt: "later", + reason: `reason_${i}`, + }, + }, + }); + } + + return pod; +} + +describe("Pods", () => { + const podTests = []; + + for (let r = 0; r < 3; r += 1) { + for (let d = 0; d < 3; d += 1) { + for (let ir = 0; ir < 3; ir += 1) { + for (let id = 0; id < 3; id += 1) { + podTests.push([r, d, ir, id]); + } + } + } + } + + describe.each(podTests)("for [%d running, %d dead] & initial [%d running, %d dead]", (running, dead, initRunning, initDead) => { + const pod = getDummyPod({ running, dead, initRunning, initDead }); + + function getNamedContainer(name: string) { + return { + image: "dummy", + imagePullPolicy: "Always", + name, + }; + } + + it("getRunningContainers should return only running and init running", () => { + const res = [ + ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_running_${index}`)), + ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_init-running_${index}`)), + ]; + + expect(pod.getRunningContainers()).toStrictEqual(res); + }); + + it("getAllContainers should return all containers", () => { + const res = [ + ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_running_${index}`)), + ...Array.from(new Array(dead), (val, index) => getNamedContainer(`container_dead_${index}`)), + ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_init-running_${index}`)), + ...Array.from(new Array(initDead), (val, index) => getNamedContainer(`container_init-dead_${index}`)), + ]; + + expect(pod.getAllContainers()).toStrictEqual(res); + }); + + it("getRestartsCount should return total restart counts", () => { + function sum(len: number): number { + let res = 0; + + for (let i = 0; i < len; i += 1) { + res += i; + } + + return res; + } + + expect(pod.getRestartsCount()).toStrictEqual(sum(running) + sum(dead)); + }); + + it("hasIssues should return false", () => { + expect(pod.hasIssues()).toStrictEqual(false); + }); + }); + + describe("getSelectedNodeOs", () => { + it("should return stable", () => { + const pod = getDummyPod(); + + pod.spec.nodeSelector = { + "kubernetes.io/os": "foobar", + }; + + expect(pod.getSelectedNodeOs()).toStrictEqual("foobar"); + }); + + it("should return beta", () => { + const pod = getDummyPod(); + + pod.spec.nodeSelector = { + "beta.kubernetes.io/os": "foobar1", + }; + + expect(pod.getSelectedNodeOs()).toStrictEqual("foobar1"); + }); + + it("should return stable over beta", () => { + const pod = getDummyPod(); + + pod.spec.nodeSelector = { + "kubernetes.io/os": "foobar2", + "beta.kubernetes.io/os": "foobar3", + }; + + expect(pod.getSelectedNodeOs()).toStrictEqual("foobar2"); + }); + + it("should return undefined if none set", () => { + const pod = getDummyPod(); + + expect(pod.getSelectedNodeOs()).toStrictEqual(undefined); + }); + }); + + describe("hasIssues", () => { + it("should return true if a condition isn't ready", () => { + const pod = getDummyPod({ running: 1 }); + + pod.status?.conditions.push({ + type: "Ready", + status: "foobar", + lastProbeTime: 1, + lastTransitionTime: "longer ago", + }); + + expect(pod.hasIssues()).toStrictEqual(true); + }); + + it("should return false if a condition is non-ready", () => { + const pod = getDummyPod({ running: 1 }); + + pod.status?.conditions.push({ + type: "dummy", + status: "foobar", + lastProbeTime: 1, + lastTransitionTime: "longer ago", + }); + + expect(pod.hasIssues()).toStrictEqual(false); + }); + + it("should return true if a current container is in a crash loop back off", () => { + const pod = getDummyPod({ running: 1 }); + const firstStatus = pod.status?.containerStatuses?.[0]; + + assert(firstStatus); + + firstStatus.state = { + waiting: { + reason: "CrashLookBackOff", + message: "too much foobar", + }, + }; + + expect(pod.hasIssues()).toStrictEqual(true); + }); + + it("should return true if a current phase isn't running", () => { + const pod = getDummyPod({ running: 1 }); + + assert(pod.status); + + pod.status.phase = "not running"; + + expect(pod.hasIssues()).toStrictEqual(true); + }); + + it("should return false if a current phase is running", () => { + const pod = getDummyPod({ running: 1 }); + + assert(pod.status); + + pod.status.phase = "Running"; + + expect(pod.hasIssues()).toStrictEqual(false); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/__tests__/stateful-set.api.test.ts b/packages/core/src/common/k8s-api/__tests__/stateful-set.api.test.ts new file mode 100644 index 0000000000..b7557c8010 --- /dev/null +++ b/packages/core/src/common/k8s-api/__tests__/stateful-set.api.test.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import storesAndApisCanBeCreatedInjectable from "../../../renderer/stores-apis-can-be-created.injectable"; +import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; +import apiKubeInjectable from "../../../renderer/k8s/api-kube.injectable"; +import type { StatefulSetApi } from "../endpoints"; +import statefulSetApiInjectable from "../endpoints/stateful-set.api.injectable"; +import type { KubeJsonApi } from "../kube-json-api"; + +describe("StatefulSetApi", () => { + let statefulSetApi: StatefulSetApi; + let kubeJsonApi: jest.Mocked; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(storesAndApisCanBeCreatedInjectable, () => true); + kubeJsonApi = { + getResponse: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + del: jest.fn(), + } as never; + di.override(apiKubeInjectable, () => kubeJsonApi); + + statefulSetApi = di.inject(statefulSetApiInjectable); + }); + + describe("scale", () => { + it("requests Kubernetes API with PATCH verb and correct amount of replicas", () => { + statefulSetApi.scale({ namespace: "default", name: "statefulset-1" }, 5); + + expect(kubeJsonApi.patch).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/statefulsets/statefulset-1/scale", { + data: { + spec: { + replicas: 5, + }, + }, + }, + { + headers: { + "content-type": "application/merge-patch+json", + }, + }); + }); + }); +}); diff --git a/packages/core/src/common/k8s-api/api-base-configs.ts b/packages/core/src/common/k8s-api/api-base-configs.ts new file mode 100644 index 0000000000..5ac67229ec --- /dev/null +++ b/packages/core/src/common/k8s-api/api-base-configs.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const apiBaseServerAddressInjectionToken = getInjectionToken({ + id: "api-base-config-server-address-token", +}); + +export const apiBaseHostHeaderInjectionToken = getInjectionToken({ + id: "api-base-host-header-token", +}); diff --git a/packages/core/src/common/k8s-api/api-base.injectable.ts b/packages/core/src/common/k8s-api/api-base.injectable.ts new file mode 100644 index 0000000000..b340882672 --- /dev/null +++ b/packages/core/src/common/k8s-api/api-base.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { apiPrefix } from "../vars"; +import isDebuggingInjectable from "../vars/is-debugging.injectable"; +import isDevelopmentInjectable from "../vars/is-development.injectable"; +import { apiBaseHostHeaderInjectionToken, apiBaseServerAddressInjectionToken } from "./api-base-configs"; +import createJsonApiInjectable from "./create-json-api.injectable"; + +const apiBaseInjectable = getInjectable({ + id: "api-base", + instantiate: (di) => { + const createJsonApi = di.inject(createJsonApiInjectable); + const isDebugging = di.inject(isDebuggingInjectable); + const isDevelopment = di.inject(isDevelopmentInjectable); + const serverAddress = di.inject(apiBaseServerAddressInjectionToken); + const hostHeaderValue = di.inject(apiBaseHostHeaderInjectionToken); + + return createJsonApi({ + serverAddress, + apiBase: apiPrefix, + debug: isDevelopment || isDebugging, + }, { + headers: { + "Host": hostHeaderValue, + }, + }); + }, +}); + +export default apiBaseInjectable; diff --git a/packages/core/src/common/k8s-api/api-kube.ts b/packages/core/src/common/k8s-api/api-kube.ts new file mode 100644 index 0000000000..58aa95208a --- /dev/null +++ b/packages/core/src/common/k8s-api/api-kube.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { KubeJsonApi } from "./kube-json-api"; + +export const apiKubeInjectionToken = getInjectionToken({ + id: "api-kube-injection-token", +}); diff --git a/packages/core/src/common/k8s-api/api-manager/api-manager.ts b/packages/core/src/common/k8s-api/api-manager/api-manager.ts new file mode 100644 index 0000000000..080ccb671a --- /dev/null +++ b/packages/core/src/common/k8s-api/api-manager/api-manager.ts @@ -0,0 +1,161 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectStore } from "../kube-object.store"; + +import { action, observable, makeObservable } from "mobx"; +import { autoBind, isDefined, iter } from "../../utils"; +import type { KubeApi } from "../kube-api"; +import type { KubeJsonApiDataFor, KubeObject, ObjectReference } from "../kube-object"; +import { parseKubeApi, createKubeApiURL } from "../kube-api-parse"; + +export type RegisterableStore = Store extends KubeObjectStore + ? Store + : never; +export type RegisterableApi = Api extends KubeApi + ? Api + : never; +export type KubeObjectStoreFrom = Api extends KubeApi + ? KubeObjectStore + : never; + +export class ApiManager { + private readonly apis = observable.map(); + private readonly stores = observable.map(); + + constructor() { + makeObservable(this); + autoBind(this); + } + + getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { + if (typeof pathOrCallback === "string") { + return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase); + } + + return iter.find(this.apis.values(), pathOrCallback ?? (() => true)); + } + + getApiByKind(kind: string, apiVersion: string) { + return iter.find(this.apis.values(), api => api.kind === kind && api.apiVersionWithGroup === apiVersion); + } + + registerApi(api: RegisterableApi): void; + /** + * @deprecated Just register the `api` by itself + */ + registerApi(apiBase: string, api: RegisterableApi): void; + registerApi(apiBaseRaw: string | RegisterableApi, apiRaw?: RegisterableApi) { + const api = typeof apiBaseRaw === "string" + ? apiRaw + : apiBaseRaw; + + if (!api?.apiBase) { + return; + } + + if (!this.apis.has(api.apiBase)) { + this.stores.forEach((store) => { + if (store.api === api) { + this.stores.set(api.apiBase, store); + } + }); + + this.apis.set(api.apiBase, api); + } + } + + protected resolveApi(api: undefined | string | KubeApi): KubeApi | undefined { + if (!api) { + return undefined; + } + + if (typeof api === "string") { + return this.getApi(api); + } + + return api; + } + + unregisterApi(api: string | KubeApi) { + if (typeof api === "string") this.apis.delete(api); + else { + const apis = Array.from(this.apis.entries()); + const entry = apis.find(entry => entry[1] === api); + + if (entry) this.unregisterApi(entry[0]); + } + } + + registerStore(store: RegisterableStore): void; + /** + * @deprecated KubeObjectStore's should only every be about a single KubeApi type + */ + registerStore(store: KubeObjectStore, KubeJsonApiDataFor>, apis: KubeApi[]): void; + + @action + registerStore(store: KubeObjectStore, KubeJsonApiDataFor>, apis: KubeApi[] = [store.api]): void { + for (const api of apis.filter(isDefined)) { + if (api.apiBase) { + this.stores.set(api.apiBase, store as never); + } + } + } + + getStore(api: string | undefined): KubeObjectStore | undefined; + getStore(api: RegisterableApi): KubeObjectStoreFrom | undefined; + /** + * @deprecated use an actual cast instead of hiding it with this unused type param + */ + getStore(api: string | KubeApi): Store | undefined ; + getStore(api: string | KubeApi | undefined): KubeObjectStore | undefined { + const { apiBase } = this.resolveApi(api) ?? {}; + + if (apiBase) { + return this.stores.get(apiBase); + } + + return undefined; + } + + lookupApiLink(ref: ObjectReference, parentObject?: KubeObject): string { + const { + kind, apiVersion = "v1", name, + namespace = parentObject?.getNs(), + } = ref; + + if (!kind) return ""; + + // search in registered apis by 'kind' & 'apiVersion' + const api = this.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion); + + if (api) { + return api.getUrl({ namespace, name }); + } + + // lookup api by generated resource link + const apiPrefixes = ["/apis", "/api"]; + const resource = kind.endsWith("s") ? `${kind.toLowerCase()}es` : `${kind.toLowerCase()}s`; + + for (const apiPrefix of apiPrefixes) { + const apiLink = createKubeApiURL({ apiPrefix, apiVersion, name, namespace, resource }); + + if (this.getApi(apiLink)) { + return apiLink; + } + } + + // resolve by kind only (hpa's might use refs to older versions of resources for example) + const apiByKind = this.getApi(api => api.kind === kind); + + if (apiByKind) { + return apiByKind.getUrl({ name, namespace }); + } + + // otherwise generate link with default prefix + // resource still might exists in k8s, but api is not registered in the app + return createKubeApiURL({ apiVersion, name, namespace, resource }); + } +} diff --git a/packages/core/src/common/k8s-api/api-manager/auto-registration-emitter.injectable.ts b/packages/core/src/common/k8s-api/api-manager/auto-registration-emitter.injectable.ts new file mode 100644 index 0000000000..d9a68a988c --- /dev/null +++ b/packages/core/src/common/k8s-api/api-manager/auto-registration-emitter.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import EventEmitter from "events"; +import type TypedEventEmitter from "typed-emitter"; +import type { CustomResourceDefinition } from "../endpoints"; +import type { KubeApi } from "../kube-api"; + +export interface LegacyAutoRegistration { + customResourceDefinition: (crd: CustomResourceDefinition) => void; + kubeApi: (api: KubeApi) => void; +} + +/** + * This is used to remove dependency cycles from auto registering of instances + * + * - Custom Resource Definitions get their own registered store (will need in the future) + * - All KubeApi's get auto registered (this should be changed in the future) + */ +const autoRegistrationEmitterInjectable = getInjectable({ + id: "auto-registration-emitter", + instantiate: (): TypedEventEmitter => new EventEmitter(), +}); + +export default autoRegistrationEmitterInjectable; diff --git a/packages/core/src/common/k8s-api/api-manager/index.ts b/packages/core/src/common/k8s-api/api-manager/index.ts new file mode 100644 index 0000000000..56182e815b --- /dev/null +++ b/packages/core/src/common/k8s-api/api-manager/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./api-manager"; diff --git a/packages/core/src/common/k8s-api/api-manager/manager.injectable.ts b/packages/core/src/common/k8s-api/api-manager/manager.injectable.ts new file mode 100644 index 0000000000..45188cec7a --- /dev/null +++ b/packages/core/src/common/k8s-api/api-manager/manager.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import type { KubeObjectStore } from "../kube-object.store"; +import { ApiManager } from "./api-manager"; + +export const kubeObjectStoreInjectionToken = getInjectionToken>({ + id: "kube-object-store-token", +}); + +const apiManagerInjectable = getInjectable({ + id: "api-manager", + instantiate: (di) => { + const apiManager = new ApiManager(); + + if (di.inject(storesAndApisCanBeCreatedInjectionToken)) { + const stores = di.injectMany(kubeObjectStoreInjectionToken); + + for (const store of stores) { + apiManager.registerStore(store); + } + } + + return apiManager; + }, +}); + +export default apiManagerInjectable; diff --git a/packages/core/src/common/k8s-api/api-manager/resource.store.ts b/packages/core/src/common/k8s-api/api-manager/resource.store.ts new file mode 100644 index 0000000000..c81ce7daec --- /dev/null +++ b/packages/core/src/common/k8s-api/api-manager/resource.store.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeApi } from "../kube-api"; +import type { KubeObjectStoreDependencies } from "../kube-object.store"; +import { KubeObjectStore } from "../kube-object.store"; +import type { KubeObject } from "../kube-object"; + +export class CustomResourceStore extends KubeObjectStore> { + constructor(deps: KubeObjectStoreDependencies, api: KubeApi) { + super(deps, api); + } +} diff --git a/packages/core/src/common/k8s-api/create-json-api.injectable.ts b/packages/core/src/common/k8s-api/create-json-api.injectable.ts new file mode 100644 index 0000000000..7f8559bf3a --- /dev/null +++ b/packages/core/src/common/k8s-api/create-json-api.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { Agent } from "https"; +import type { RequestInit } from "node-fetch"; +import lensProxyCertificateInjectable from "../certificate/lens-proxy-certificate.injectable"; +import fetchInjectable from "../fetch/fetch.injectable"; +import loggerInjectable from "../logger.injectable"; +import type { JsonApiConfig, JsonApiData, JsonApiDependencies, JsonApiParams } from "./json-api"; +import { JsonApi } from "./json-api"; + +export type CreateJsonApi = = JsonApiParams>(config: JsonApiConfig, reqInit?: RequestInit) => JsonApi; + +const createJsonApiInjectable = getInjectable({ + id: "create-json-api", + instantiate: (di): CreateJsonApi => { + const deps: JsonApiDependencies = { + fetch: di.inject(fetchInjectable), + logger: di.inject(loggerInjectable), + }; + const lensProxyCert = di.inject(lensProxyCertificateInjectable); + + return (config, reqInit) => { + if (!config.getRequestOptions) { + config.getRequestOptions = async () => { + const agent = new Agent({ + ca: lensProxyCert.get().cert, + }); + + return { + agent, + }; + }; + } + + return new JsonApi(deps, config, reqInit); + }; + }, +}); + +export default createJsonApiInjectable; diff --git a/packages/core/src/common/k8s-api/create-kube-api-for-cluster.injectable.ts b/packages/core/src/common/k8s-api/create-kube-api-for-cluster.injectable.ts new file mode 100644 index 0000000000..dc3a3fef69 --- /dev/null +++ b/packages/core/src/common/k8s-api/create-kube-api-for-cluster.injectable.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../logger.injectable"; +import { apiKubePrefix } from "../vars"; +import isDevelopmentInjectable from "../vars/is-development.injectable"; +import apiBaseInjectable from "./api-base.injectable"; +import type { KubeApiConstructor } from "./create-kube-api-for-remote-cluster.injectable"; +import createKubeJsonApiInjectable from "./create-kube-json-api.injectable"; +import { KubeApi } from "./kube-api"; +import type { KubeJsonApiDataFor, KubeObject, KubeObjectConstructor } from "./kube-object"; + +export interface CreateKubeApiForLocalClusterConfig { + metadata: { + uid: string; + }; +} + +export interface CreateKubeApiForCluster { + , Data extends KubeJsonApiDataFor>( + cluster: CreateKubeApiForLocalClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass: KubeApiConstructor, + ): Api; + >( + cluster: CreateKubeApiForLocalClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass?: KubeApiConstructor>, + ): KubeApi; +} + +const createKubeApiForClusterInjectable = getInjectable({ + id: "create-kube-api-for-cluster", + instantiate: (di): CreateKubeApiForCluster => { + const apiBase = di.inject(apiBaseInjectable); + const isDevelopment = di.inject(isDevelopmentInjectable); + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + const logger = di.inject(loggerInjectable); + + return ( + cluster: CreateKubeApiForLocalClusterConfig, + kubeClass: KubeObjectConstructor>, + apiClass?: KubeApiConstructor>, + ) => { + const request = createKubeJsonApi( + { + serverAddress: apiBase.config.serverAddress, + apiBase: apiKubePrefix, + debug: isDevelopment, + }, { + headers: { + "Host": `${cluster.metadata.uid}.lens.app:${new URL(apiBase.config.serverAddress).port}`, + }, + }); + + if (apiClass) { + return new apiClass({ + objectConstructor: kubeClass, + request, + }); + } + + return new KubeApi( + { + logger, + maybeKubeApi: undefined, + }, + { + objectConstructor: kubeClass, + request, + }, + ); + }; + }, +}); + +export default createKubeApiForClusterInjectable; diff --git a/packages/core/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts b/packages/core/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts new file mode 100644 index 0000000000..8b39387d1d --- /dev/null +++ b/packages/core/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AgentOptions } from "https"; +import { Agent } from "https"; +import type { RequestInit } from "node-fetch"; +import loggerInjectable from "../logger.injectable"; +import isDevelopmentInjectable from "../vars/is-development.injectable"; +import createKubeJsonApiInjectable from "./create-kube-json-api.injectable"; +import type { KubeApiOptions } from "./kube-api"; +import { KubeApi } from "./kube-api"; +import type { KubeJsonApiDataFor, KubeObject, KubeObjectConstructor } from "./kube-object"; + +export interface CreateKubeApiForRemoteClusterConfig { + cluster: { + server: string; + caData?: string; + skipTLSVerify?: boolean; + }; + user: { + token?: string | (() => Promise); + clientCertificateData?: string; + clientKeyData?: string; + }; + /** + * Custom instance of https.agent to use for the requests + * + * @remarks the custom agent replaced default agent, options skipTLSVerify, + * clientCertificateData, clientKeyData and caData are ignored. + */ + agent?: Agent; +} + +export type KubeApiConstructor> = new (apiOpts: KubeApiOptions) => Api; + +export interface CreateKubeApiForRemoteCluster { + , Data extends KubeJsonApiDataFor>( + config: CreateKubeApiForRemoteClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass: KubeApiConstructor, + ): Api; + >( + config: CreateKubeApiForRemoteClusterConfig, + kubeClass: KubeObjectConstructor, + apiClass?: KubeApiConstructor>, + ): KubeApi; +} + +const createKubeApiForRemoteClusterInjectable = getInjectable({ + id: "create-kube-api-for-remote-cluster", + instantiate: (di): CreateKubeApiForRemoteCluster => { + const isDevelopment = di.inject(isDevelopmentInjectable); + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + const logger = di.inject(loggerInjectable); + + return ( + config: CreateKubeApiForRemoteClusterConfig, + kubeClass: KubeObjectConstructor>, + apiClass?: KubeApiConstructor>, + ) => { + const reqInit: RequestInit = {}; + const agentOptions: AgentOptions = {}; + + if (config.cluster.skipTLSVerify === true) { + agentOptions.rejectUnauthorized = false; + } + + if (config.user.clientCertificateData) { + agentOptions.cert = config.user.clientCertificateData; + } + + if (config.user.clientKeyData) { + agentOptions.key = config.user.clientKeyData; + } + + if (config.cluster.caData) { + agentOptions.ca = config.cluster.caData; + } + + if (Object.keys(agentOptions).length > 0) { + reqInit.agent = new Agent(agentOptions); + } + + if (config.agent) { + reqInit.agent = config.agent; + } + + const token = config.user.token; + const request = createKubeJsonApi({ + serverAddress: config.cluster.server, + apiBase: "", + debug: isDevelopment, + ...(token ? { + getRequestOptions: async () => ({ + headers: { + "Authorization": `Bearer ${typeof token === "function" ? await token() : token}`, + }, + }), + } : {}), + }, reqInit); + + if (apiClass) { + return new apiClass({ + objectConstructor: kubeClass, + request, + }); + } + + return new KubeApi( + { + logger, + maybeKubeApi: undefined, + }, + { + objectConstructor: kubeClass, + request, + }, + ); + }; + }, +}); + +export default createKubeApiForRemoteClusterInjectable; diff --git a/packages/core/src/common/k8s-api/create-kube-api.injectable.ts b/packages/core/src/common/k8s-api/create-kube-api.injectable.ts new file mode 100644 index 0000000000..cce470b132 --- /dev/null +++ b/packages/core/src/common/k8s-api/create-kube-api.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../logger.injectable"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "./kube-api"; +import maybeKubeApiInjectable from "./maybe-kube-api.injectable"; + +export interface CreateKubeApi { + (ctor: new (deps: KubeApiDependencies, opts: DerivedKubeApiOptions) => Api, opts?: DerivedKubeApiOptions): Api; +} + +const createKubeApiInjectable = getInjectable({ + id: "create-kube-api", + instantiate: (di): CreateKubeApi => { + const deps: KubeApiDependencies = { + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }; + + return (ctor, opts) => new ctor(deps, opts ?? {}); + }, +}); + +export default createKubeApiInjectable; diff --git a/packages/core/src/common/k8s-api/create-kube-json-api-for-cluster.injectable.ts b/packages/core/src/common/k8s-api/create-kube-json-api-for-cluster.injectable.ts new file mode 100644 index 0000000000..799b0bf963 --- /dev/null +++ b/packages/core/src/common/k8s-api/create-kube-json-api-for-cluster.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { apiKubePrefix } from "../vars"; +import isDebuggingInjectable from "../vars/is-debugging.injectable"; +import { apiBaseHostHeaderInjectionToken, apiBaseServerAddressInjectionToken } from "./api-base-configs"; +import createKubeJsonApiInjectable from "./create-kube-json-api.injectable"; +import type { KubeJsonApi } from "./kube-json-api"; + +export type CreateKubeJsonApiForCluster = (clusterId: string) => KubeJsonApi; + +const createKubeJsonApiForClusterInjectable = getInjectable({ + id: "create-kube-json-api-for-cluster", + instantiate: (di): CreateKubeJsonApiForCluster => { + const createKubeJsonApi = di.inject(createKubeJsonApiInjectable); + const isDebugging = di.inject(isDebuggingInjectable); + + return (clusterId) => createKubeJsonApi( + { + serverAddress: di.inject(apiBaseServerAddressInjectionToken), + apiBase: apiKubePrefix, + debug: isDebugging, + }, + { + headers: { + "Host": `${clusterId}.${di.inject(apiBaseHostHeaderInjectionToken)}`, + }, + }, + ); + }, +}); + +export default createKubeJsonApiForClusterInjectable; diff --git a/packages/core/src/common/k8s-api/create-kube-json-api.injectable.ts b/packages/core/src/common/k8s-api/create-kube-json-api.injectable.ts new file mode 100644 index 0000000000..f7b5a152ee --- /dev/null +++ b/packages/core/src/common/k8s-api/create-kube-json-api.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { Agent } from "https"; +import type { RequestInit } from "node-fetch"; +import lensProxyCertificateInjectable from "../certificate/lens-proxy-certificate.injectable"; +import fetchInjectable from "../fetch/fetch.injectable"; +import loggerInjectable from "../logger.injectable"; +import type { JsonApiConfig, JsonApiDependencies } from "./json-api"; +import { KubeJsonApi } from "./kube-json-api"; + +export type CreateKubeJsonApi = (config: JsonApiConfig, reqInit?: RequestInit) => KubeJsonApi; + +const createKubeJsonApiInjectable = getInjectable({ + id: "create-kube-json-api", + instantiate: (di): CreateKubeJsonApi => { + const dependencies: JsonApiDependencies = { + fetch: di.inject(fetchInjectable), + logger: di.inject(loggerInjectable), + }; + const lensProxyCert = di.inject(lensProxyCertificateInjectable); + + return (config, reqInit) => { + if (!config.getRequestOptions) { + config.getRequestOptions = async () => { + const agent = new Agent({ + ca: lensProxyCert.get().cert, + }); + + return { + agent, + }; + }; + } + + return new KubeJsonApi(dependencies, config, reqInit); + }; + }, +}); + +export default createKubeJsonApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts new file mode 100644 index 0000000000..4965a86edc --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ClusterRoleBindingApi } from "./cluster-role-binding.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const clusterRoleBindingApiInjectable = getInjectable({ + id: "cluster-role-binding-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "clusterRoleBindingApi is only accessible in certain environments"); + + return new ClusterRoleBindingApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default clusterRoleBindingApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/cluster-role-binding.api.ts b/packages/core/src/common/k8s-api/endpoints/cluster-role-binding.api.ts new file mode 100644 index 0000000000..827a3ce95d --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cluster-role-binding.api.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { ClusterScopedMetadata, KubeObjectMetadata, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { RoleRef } from "./types/role-ref"; +import type { Subject } from "./types/subject"; + +export interface ClusterRoleBindingData extends KubeJsonApiData, void, void> { + subjects?: Subject[]; + roleRef: RoleRef; +} + +export class ClusterRoleBinding extends KubeObject< + ClusterScopedMetadata, + void, + void +> { + static kind = "ClusterRoleBinding"; + static namespaced = false; + static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings"; + + subjects?: Subject[]; + roleRef: RoleRef; + + constructor({ + subjects, + roleRef, + ...rest + }: ClusterRoleBindingData) { + super(rest); + this.subjects = subjects; + this.roleRef = roleRef; + } + + getSubjects() { + return this.subjects ?? []; + } + + getSubjectNames(): string { + return this.getSubjects().map(subject => subject.name).join(", "); + } +} + +export class ClusterRoleBindingApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: ClusterRoleBinding, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts new file mode 100644 index 0000000000..b22e198d1e --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ClusterRoleApi } from "./cluster-role.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const clusterRoleApiInjectable = getInjectable({ + id: "cluster-role-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "clusterRoleApi is only available in certain environments"); + + return new ClusterRoleApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default clusterRoleApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/cluster-role.api.ts b/packages/core/src/common/k8s-api/endpoints/cluster-role.api.ts new file mode 100644 index 0000000000..e55f934df9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cluster-role.api.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { ClusterScopedMetadata, KubeObjectMetadata, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { AggregationRule } from "./types/aggregation-rule"; +import type { PolicyRule } from "./types/policy-rule"; + +export interface ClusterRoleData extends KubeJsonApiData, void, void> { + rules?: PolicyRule[]; + aggregationRule?: AggregationRule; +} + +export class ClusterRole extends KubeObject< + ClusterScopedMetadata, + void, + void +> { + static kind = "ClusterRole"; + static namespaced = false; + static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles"; + + rules?: PolicyRule[]; + aggregationRule?: AggregationRule; + + constructor({ rules, aggregationRule, ...rest }: ClusterRoleData) { + super(rest); + this.rules = rules; + this.aggregationRule = aggregationRule; + } + + getRules() { + return this.rules || []; + } +} + +export class ClusterRoleApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: ClusterRole, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/cluster.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/cluster.api.injectable.ts new file mode 100644 index 0000000000..494ef9d94b --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cluster.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ClusterApi } from "./cluster.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const clusterApiInjectable = getInjectable({ + id: "cluster-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "clusterApi is only available in certain environments"); + + return new ClusterApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default clusterApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/cluster.api.ts b/packages/core/src/common/k8s-api/endpoints/cluster.api.ts new file mode 100644 index 0000000000..92f42f1e4e --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cluster.api.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export class ClusterApi extends KubeApi { + /** + * @deprecated This field is legacy and never used. + */ + static kind = "Cluster"; + + /** + * @deprecated This field is legacy and never used. + */ + static namespaced = true; + + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Cluster, + }); + } +} + +export enum ClusterStatus { + ACTIVE = "Active", + CREATING = "Creating", + REMOVING = "Removing", + ERROR = "Error", +} + +export interface Cluster { + spec: { + clusterNetwork?: { + serviceDomain?: string; + pods?: { + cidrBlocks?: string[]; + }; + services?: { + cidrBlocks?: string[]; + }; + }; + providerSpec: { + value: { + profile: string; + }; + }; + }; + status?: { + apiEndpoints: { + host: string; + port: string; + }[]; + providerStatus: { + adminUser?: string; + adminPassword?: string; + kubeconfig?: string; + processState?: string; + lensAddress?: string; + }; + errorMessage?: string; + errorReason?: string; + }; +} + +export class Cluster extends KubeObject { + static kind = "Cluster"; + static apiBase = "/apis/cluster.k8s.io/v1alpha1/clusters"; + static namespaced = true; + + getStatus() { + if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING; + if (!this.status || !this.status) return ClusterStatus.CREATING; + if (this.status.errorMessage) return ClusterStatus.ERROR; + + return ClusterStatus.ACTIVE; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/component-status.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/component-status.api.injectable.ts new file mode 100644 index 0000000000..01bb16e6b4 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/component-status.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ComponentStatusApi } from "./component-status.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; +import loggerInjectable from "../../logger.injectable"; + +const componentStatusApiInjectable = getInjectable({ + id: "component-status-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "componentStatusApi is only available in certain environments"); + + return new ComponentStatusApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default componentStatusApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/component-status.api.ts b/packages/core/src/common/k8s-api/endpoints/component-status.api.ts new file mode 100644 index 0000000000..a35fc92c44 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/component-status.api.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export interface ComponentStatusCondition { + type: string; + status: string; + message: string; +} + +export interface ComponentStatus { + conditions: ComponentStatusCondition[]; +} + +export class ComponentStatus extends KubeObject { + static kind = "ComponentStatus"; + static namespaced = false; + static apiBase = "/api/v1/componentstatuses"; + + getTruthyConditions() { + return this.conditions.filter(c => c.status === "True"); + } +} + +export class ComponentStatusApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: ComponentStatus, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/config-map.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/config-map.api.injectable.ts new file mode 100644 index 0000000000..e41cc23111 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/config-map.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ConfigMapApi } from "./config-map.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const configMapApiInjectable = getInjectable({ + id: "config-map-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "configMapApi is only available in certain environments"); + + return new ConfigMapApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default configMapApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/config-map.api.ts b/packages/core/src/common/k8s-api/endpoints/config-map.api.ts new file mode 100644 index 0000000000..a2860246b1 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/config-map.api.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import { autoBind } from "../../utils"; + +export interface ConfigMapData extends KubeJsonApiData, void, void> { + data?: Partial>; + binaryData?: Partial>; + immutable?: boolean; +} + +export class ConfigMap extends KubeObject< + NamespaceScopedMetadata, + void, + void +> { + static kind = "ConfigMap"; + static namespaced = true; + static apiBase = "/api/v1/configmaps"; + + data: Partial>; + binaryData: Partial>; + immutable?: boolean; + + constructor({ data, binaryData, immutable, ...rest }: ConfigMapData) { + super(rest); + autoBind(this); + + this.data = data ?? {}; + this.binaryData = binaryData ?? {}; + this.immutable = immutable; + } + + getKeys(): string[] { + return Object.keys(this.data); + } +} + +export class ConfigMapApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + objectConstructor: ConfigMap, + ...opts ?? {}, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/cron-job.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/cron-job.api.injectable.ts new file mode 100644 index 0000000000..2831a726aa --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cron-job.api.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { CronJobApi } from "./cron-job.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const cronJobApiInjectable = getInjectable({ + id: "cron-job-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "cronJobApi is only available in certain environments"); + + return new CronJobApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }, { + checkPreferredVersion: true, + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default cronJobApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/cron-job.api.ts b/packages/core/src/common/k8s-api/endpoints/cron-job.api.ts new file mode 100644 index 0000000000..2ccb8c910c --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/cron-job.api.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import moment from "moment"; +import type { NamespaceScopedMetadata, ObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { formatDuration } from "../../utils/formatDuration"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { JobTemplateSpec } from "./types/job-template-spec"; + +export class CronJobApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions) { + super(deps, { + ...opts, + objectConstructor: CronJob, + }); + } + + suspend(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + suspend: true, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } + + resume(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + suspend: false, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } +} + +export interface CronJobSpec { + concurrencyPolicy?: string; + failedJobsHistoryLimit?: number; + jobTemplate?: JobTemplateSpec; + schedule: string; + startingDeadlineSeconds?: number; + successfulJobsHistoryLimit?: number; + suspend?: boolean; +} + +export interface CronJobStatus { + lastScheduleTime?: string; + lastSuccessfulTime?: string; + active?: ObjectReference[]; +} + +export class CronJob extends KubeObject< + NamespaceScopedMetadata, + CronJobStatus, + CronJobSpec +> { + static readonly kind = "CronJob"; + static readonly namespaced = true; + static readonly apiBase = "/apis/batch/v1/cronjobs"; + + getSuspendFlag() { + return (this.spec.suspend ?? false).toString(); + } + + getLastScheduleTime() { + if (!this.status?.lastScheduleTime) return "-"; + const diff = moment().diff(this.status.lastScheduleTime); + + return formatDuration(diff, true); + } + + getSchedule() { + return this.spec.schedule; + } + + isNeverRun() { + const schedule = this.getSchedule(); + const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const stamps = schedule.split(" "); + const day = Number(stamps[stamps.length - 3]); // 1-31 + const month = Number(stamps[stamps.length - 2]); // 1-12 + + if (schedule.startsWith("@")) return false; + + return day > daysInMonth[month - 1]; + } + + isSuspend() { + return this.spec.suspend; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts new file mode 100644 index 0000000000..cf8a6560a0 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { CustomResourceDefinitionApi } from "./custom-resource-definition.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; +import loggerInjectable from "../../logger.injectable"; + +const customResourceDefinitionApiInjectable = getInjectable({ + id: "custom-resource-definition-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "customResourceDefinitionApi is only available in certain environments"); + + return new CustomResourceDefinitionApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default customResourceDefinitionApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/custom-resource-definition.api.ts b/packages/core/src/common/k8s-api/endpoints/custom-resource-definition.api.ts new file mode 100644 index 0000000000..b438f06b3a --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/custom-resource-definition.api.ts @@ -0,0 +1,244 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getLegacyGlobalDiForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import customResourcesRouteInjectable from "../../front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable"; +import { buildURL } from "../../utils/buildUrl"; +import type { BaseKubeObjectCondition, ClusterScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { JSONSchemaProps } from "./types/json-schema-props"; + +interface AdditionalPrinterColumnsCommon { + name: string; + type: "integer" | "number" | "string" | "boolean" | "date"; + priority?: number; + format?: "int32" | "int64" | "float" | "double" | "byte" | "binary" | "date" | "date-time" | "password"; + description?: string; +} + +export type AdditionalPrinterColumnsV1 = AdditionalPrinterColumnsCommon & { + jsonPath: string; +}; + +type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & { + JSONPath: string; +}; + +export interface CustomResourceValidation { + openAPIV3Schema?: JSONSchemaProps; +} + +export interface CustomResourceDefinitionVersion { + name: string; + served: boolean; + storage: boolean; + schema?: CustomResourceValidation; // required in v1 but not present in v1beta + additionalPrinterColumns?: AdditionalPrinterColumnsV1[]; +} + +export interface CustomResourceDefinitionNames { + categories?: string[]; + kind: string; + listKind?: string; + plural: string; + shortNames?: string[]; + singular?: string; +} + +export interface CustomResourceConversion { + strategy?: string; + webhook?: WebhookConversion; +} + +export interface WebhookConversion { + clientConfig?: WebhookClientConfig[]; + conversionReviewVersions: string[]; +} + +export interface WebhookClientConfig { + caBundle?: string; + url?: string; + service?: ServiceReference; +} + +export interface ServiceReference { + name: string; + namespace: string; + path?: string; + port?: number; +} + +export interface CustomResourceDefinitionSpec { + group: string; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + version?: string; + names: CustomResourceDefinitionNames; + scope: "Namespaced" | "Cluster"; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + validation?: object; + versions?: CustomResourceDefinitionVersion[]; + conversion?: CustomResourceConversion; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; + preserveUnknownFields?: boolean; +} + +export interface CustomResourceDefinitionConditionAcceptedNames { + plural: string; + singular: string; + kind: string; + shortNames: string[]; + listKind: string; +} + +export interface CustomResourceDefinitionStatus { + conditions?: BaseKubeObjectCondition[]; + acceptedNames: CustomResourceDefinitionConditionAcceptedNames; + storedVersions: string[]; +} + +export class CustomResourceDefinition extends KubeObject< + ClusterScopedMetadata, + CustomResourceDefinitionStatus, + CustomResourceDefinitionSpec +> { + static kind = "CustomResourceDefinition"; + static namespaced = false; + static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions"; + + getResourceUrl() { + const di = getLegacyGlobalDiForExtensionApi(); + + const customResourcesRoute = di.inject(customResourcesRouteInjectable); + + return buildURL(customResourcesRoute.path, { + params: { + group: this.getGroup(), + name: this.getPluralName(), + }, + }); + } + + getResourceApiBase() { + const { group } = this.spec; + + return `/apis/${group}/${this.getVersion()}/${this.getPluralName()}`; + } + + getPluralName() { + return this.getNames().plural; + } + + getResourceKind() { + return this.spec.names.kind; + } + + getResourceTitle() { + const name = this.getPluralName(); + + return name[0].toUpperCase() + name.slice(1); + } + + getGroup() { + return this.spec.group; + } + + getScope() { + return this.spec.scope; + } + + getPreferedVersion(): CustomResourceDefinitionVersion { + const { apiVersion } = this; + + switch (apiVersion) { + case "apiextensions.k8s.io/v1": + for (const version of this.spec.versions ?? []) { + if (version.storage) { + return version; + } + } + break; + + case "apiextensions.k8s.io/v1beta1": { + const { additionalPrinterColumns: apc } = this.spec; + const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc }) => ({ ...apc, jsonPath: JSONPath })); + + return { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + name: this.spec.version!, + served: true, + storage: true, + schema: this.spec.validation, + additionalPrinterColumns, + }; + } + } + + throw new Error(`Unknown apiVersion=${apiVersion}: Failed to find a version for CustomResourceDefinition ${this.metadata.name}`); + } + + getVersion() { + return this.getPreferedVersion().name; + } + + isNamespaced() { + return this.getScope() === "Namespaced"; + } + + getStoredVersions() { + return this.status?.storedVersions.join(", ") ?? ""; + } + + getNames() { + return this.spec.names; + } + + getConversion() { + return JSON.stringify(this.spec.conversion); + } + + getPrinterColumns(ignorePriority = true): AdditionalPrinterColumnsV1[] { + const columns = this.getPreferedVersion().additionalPrinterColumns ?? []; + + return columns + .filter(column => column.name.toLowerCase() != "age" && (ignorePriority || !column.priority)); + } + + getValidation() { + return JSON.stringify(this.getPreferedVersion().schema, null, 2); + } + + getConditions() { + if (!this.status?.conditions) return []; + + return this.status.conditions.map(condition => { + const { message, reason, lastTransitionTime, status } = condition; + + return { + ...condition, + isReady: status === "True", + tooltip: `${message || reason} (${lastTransitionTime})`, + }; + }); + } +} + +export class CustomResourceDefinitionApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: CustomResourceDefinition, + checkPreferredVersion: true, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts new file mode 100644 index 0000000000..8a904519b6 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { DaemonSetApi } from "./daemon-set.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const daemonSetApiInjectable = getInjectable({ + id: "daemon-set-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "daemonSetApi is only available in certain environements"); + + return new DaemonSetApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default daemonSetApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/daemon-set.api.ts b/packages/core/src/common/k8s-api/endpoints/daemon-set.api.ts new file mode 100644 index 0000000000..6661e2ff43 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/daemon-set.api.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import moment from "moment"; + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; + +export interface RollingUpdateDaemonSet { + maxUnavailable?: number | string; + maxSurge?: number | string; +} + +export interface DaemonSetUpdateStrategy { + type: string; + rollingUpdate: RollingUpdateDaemonSet; +} + +export interface DaemonSetSpec { + selector: LabelSelector; + template: PodTemplateSpec; + updateStrategy: DaemonSetUpdateStrategy; + minReadySeconds?: number; + revisionHistoryLimit?: number; +} + +export interface DaemonSetStatus extends KubeObjectStatus { + collisionCount?: number; + currentNumberScheduled: number; + desiredNumberScheduled: number; + numberAvailable?: number; + numberMisscheduled: number; + numberReady: number; + numberUnavailable?: number; + observedGeneration?: number; + updatedNumberScheduled?: number; +} + +export class DaemonSet extends KubeObject< + NamespaceScopedMetadata, + DaemonSetStatus, + DaemonSetSpec +> { + static kind = "DaemonSet"; + static namespaced = true; + static apiBase = "/apis/apps/v1/daemonsets"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); + } + + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata?.labels); + } + + getTolerations() { + return this.spec.template.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec?.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } + + getImages() { + const containers = this.spec.template?.spec?.containers ?? []; + const initContainers = this.spec.template?.spec?.initContainers ?? []; + + return [...containers, ...initContainers].map(container => container.image); + } +} + +export class DaemonSetApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: DaemonSet, + }); + } + + restart(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + template: { + metadata: { + annotations: { "kubectl.kubernetes.io/restartedAt" : moment.utc().format() }, + }, + }, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/deployment.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/deployment.api.injectable.ts new file mode 100644 index 0000000000..47236b1a6f --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/deployment.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { DeploymentApi } from "./deployment.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const deploymentApiInjectable = getInjectable({ + id: "deployment-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "deploymentApi is only available in certain environments"); + + return new DeploymentApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default deploymentApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/deployment.api.ts b/packages/core/src/common/k8s-api/endpoints/deployment.api.ts new file mode 100644 index 0000000000..31bc55ae76 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/deployment.api.ts @@ -0,0 +1,153 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import moment from "moment"; + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { PodSpec } from "./pod.api"; +import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { hasTypedProperty, isNumber, isObject } from "../../utils"; + +export class DeploymentApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Deployment, + }); + } + + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return `${this.getUrl(params)}/scale`; + } + + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + if (isObject(status) && hasTypedProperty(status, "replicas", isNumber)) { + return status.replicas; + } + + return 0; + } + + scale(params: { namespace: string; name: string }, replicas: number) { + return this.request.patch(this.getScaleApiUrl(params), { + data: { + spec: { + replicas, + }, + }, + }, + { + headers: { + "content-type": "application/merge-patch+json", + }, + }); + } + + restart(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + template: { + metadata: { + annotations: { "kubectl.kubernetes.io/restartedAt" : moment.utc().format() }, + }, + }, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } +} + +export interface DeploymentSpec { + replicas: number; + selector: LabelSelector; + template: { + metadata: { + creationTimestamp?: string; + labels: Partial>; + annotations?: Partial>; + }; + spec: PodSpec; + }; + strategy: { + type: string; + rollingUpdate: { + maxUnavailable: number; + maxSurge: number; + }; + }; +} + +export interface DeploymentStatus extends KubeObjectStatus { + observedGeneration: number; + replicas: number; + updatedReplicas: number; + readyReplicas: number; + availableReplicas?: number; + unavailableReplicas?: number; +} + +export class Deployment extends KubeObject< + NamespaceScopedMetadata, + DeploymentStatus, + DeploymentSpec +> { + static kind = "Deployment"; + static namespaced = true; + static apiBase = "/apis/apps/v1/deployments"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); + } + + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata.labels); + } + + getTolerations() { + return this.spec.template.spec.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } + + getConditions(activeOnly = false) { + const { conditions = [] } = this.status ?? {}; + + if (activeOnly) { + return conditions.filter(c => c.status === "True"); + } + + return conditions; + } + + getConditionsText(activeOnly = true) { + return this.getConditions(activeOnly) + .map(({ type }) => type) + .join(" "); + } + + getReplicas() { + return this.spec.replicas || 0; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/endpoint.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/endpoint.api.injectable.ts new file mode 100644 index 0000000000..6288dc2448 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/endpoint.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { EndpointsApi } from "./endpoint.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const endpointsApiInjectable = getInjectable({ + id: "endpoints-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "endpointsApi is only available in certain environments"); + + return new EndpointsApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default endpointsApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/endpoint.api.ts b/packages/core/src/common/k8s-api/endpoints/endpoint.api.ts new file mode 100644 index 0000000000..c60377a887 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/endpoint.api.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { autoBind } from "../../utils"; +import type { KubeObjectMetadata, KubeObjectScope, NamespaceScopedMetadata, ObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; + +export function formatEndpointSubset(subset: EndpointSubset): string { + const { addresses, ports } = subset; + + if (!addresses || !ports) { + return ""; + } + + return addresses + .map(address => ( + ports + .map(port => `${address.ip}:${port.port}`) + .join(", ") + )) + .join(", "); + +} + +export interface ForZone { + name: string; +} + +export interface EndpointHints { + forZones?: ForZone[]; +} + +export interface EndpointConditions { + ready?: boolean; + serving?: boolean; + terminating?: boolean; +} + +export interface EndpointData { + addresses: string[]; + conditions?: EndpointConditions; + hints?: EndpointHints; + hostname?: string; + nodeName?: string; + targetRef?: ObjectReference; + zone?: string; +} + +export interface EndpointPort { + appProtocol?: string; + name?: string; + protocol?: string; + port: number; +} + +export interface EndpointAddress { + hostname?: string; + ip: string; + nodeName?: string; + targetRef?: ObjectReference; +} + +export interface EndpointSubset { + addresses?: EndpointAddress[]; + notReadyAddresses?: EndpointAddress[]; + ports?: EndpointPort[]; +} + +export interface EndpointsData extends KubeJsonApiData, void, void> { + subsets?: EndpointSubset[]; +} + +export class Endpoints extends KubeObject< + NamespaceScopedMetadata, + void, + void +> { + static kind = "Endpoints"; + static namespaced = true; + static apiBase = "/api/v1/endpoints"; + + subsets?: EndpointSubset[]; + + constructor({ subsets, ...rest }: EndpointsData) { + super(rest); + autoBind(this); + this.subsets = subsets; + } + + getEndpointSubsets(): Required[] { + return this.subsets?.map(({ + addresses = [], + notReadyAddresses = [], + ports = [], + }) => ({ + addresses, + notReadyAddresses, + ports, + })) ?? []; + } + + toString(): string { + return this.getEndpointSubsets() + .map(formatEndpointSubset) + .join(", ") || ""; + } +} + +export class EndpointsApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: Endpoints, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/events.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/events.api.injectable.ts new file mode 100644 index 0000000000..a64afb877e --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/events.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { KubeEventApi } from "./events.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const kubeEventApiInjectable = getInjectable({ + id: "kube-event-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "kubeEventApi is only available in certain environments"); + + return new KubeEventApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default kubeEventApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/events.api.ts b/packages/core/src/common/k8s-api/endpoints/events.api.ts new file mode 100644 index 0000000000..aca77712c8 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/events.api.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import moment from "moment"; +import type { KubeObjectMetadata, KubeObjectScope, ObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { formatDuration } from "../../utils/formatDuration"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; + +export interface EventSeries { + count?: number; + lastObservedTime?: string; +} + +export interface EventSource { + component?: string; + host?: string; +} + +export interface KubeEventData extends KubeJsonApiData, void, void> { + action?: string; + count?: number; + eventTime?: string; + firstTimestamp?: string; + involvedObject: Required; + lastTimestamp?: string; + message?: string; + reason?: string; + related?: ObjectReference; + reportingComponent?: string; + reportingInstance?: string; + series?: EventSeries; + source?: EventSource; + type?: string; +} + +export class KubeEvent extends KubeObject, void, void> { + static kind = "Event"; + static namespaced = true; + static apiBase = "/api/v1/events"; + + action?: string; + count?: number; + eventTime?: string; + firstTimestamp?: string; + involvedObject: Required; + lastTimestamp?: string; + message?: string; + reason?: string; + related?: ObjectReference; + reportingComponent?: string; + reportingInstance?: string; + series?: EventSeries; + source?: EventSource; + + /** + * Current supported values are: + * - "Normal" + * - "Warning" + */ + type?: string; + + constructor({ + action, + count, + eventTime, + firstTimestamp, + involvedObject, + lastTimestamp, + message, + reason, + related, + reportingComponent, + reportingInstance, + series, + source, + type, + ...rest + }: KubeEventData) { + super(rest); + this.action = action; + this.count = count; + this.eventTime = eventTime; + this.firstTimestamp = firstTimestamp; + this.involvedObject = involvedObject; + this.lastTimestamp = lastTimestamp; + this.message = message; + this.reason = reason; + this.related = related; + this.reportingComponent = reportingComponent; + this.reportingInstance = reportingInstance; + this.series = series; + this.source = source; + this.type = type; + } + + isWarning() { + return this.type === "Warning"; + } + + getSource() { + if (!this.source?.component) { + return ""; + } + + const { component, host = "" } = this.source; + + return `${component} ${host}`; + } + + /** + * @deprecated This function is not reactive to changing of time. If rendering use `` instead + */ + getFirstSeenTime() { + const diff = moment().diff(this.firstTimestamp); + + return formatDuration(diff, true); + } + + /** + * @deprecated This function is not reactive to changing of time. If rendering use `` instead + */ + getLastSeenTime() { + const diff = moment().diff(this.lastTimestamp); + + return formatDuration(diff, true); + } +} + +export class KubeEventApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: KubeEvent, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/helm-charts.api.ts b/packages/core/src/common/k8s-api/endpoints/helm-charts.api.ts new file mode 100644 index 0000000000..26da740830 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-charts.api.ts @@ -0,0 +1,334 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { autoBind, bifurcateArray } from "../../utils"; +import Joi from "joi"; + +export interface RawHelmChart { + apiVersion: string; + name: string; + version: string; + repo: string; + created: string; + digest?: string; + kubeVersion?: string; + description?: string; + home?: string; + engine?: string; + icon?: string; + appVersion?: string; + type?: string; + tillerVersion?: string; + deprecated?: boolean; + keywords?: string[]; + sources?: string[]; + urls?: string[]; + maintainers?: HelmChartMaintainer[]; + dependencies?: RawHelmChartDependency[]; + annotations?: Record; +} + +const helmChartMaintainerValidator = Joi.object({ + name: Joi + .string() + .required(), + email: Joi + .string() + .required(), + url: Joi + .string() + .optional(), +}); + +const helmChartDependencyValidator = Joi.object({ + name: Joi + .string() + .required(), + repository: Joi + .string() + .required(), + condition: Joi + .string() + .optional(), + version: Joi + .string() + .required(), + tags: Joi + .array() + .items(Joi.string()) + .default(() => ([])), +}); + +const helmChartValidator = Joi.object({ + apiVersion: Joi + .string() + .required(), + name: Joi + .string() + .required(), + version: Joi + .string() + .required(), + repo: Joi + .string() + .required(), + created: Joi + .string() + .required(), + digest: Joi + .string() + .optional(), + kubeVersion: Joi + .string() + .optional(), + description: Joi + .string() + .default(""), + home: Joi + .string() + .optional(), + engine: Joi + .string() + .optional(), + icon: Joi + .string() + .optional(), + appVersion: Joi + .string() + .optional(), + tillerVersion: Joi + .string() + .optional(), + type: Joi + .string() + .optional(), + deprecated: Joi + .boolean() + .default(false), + keywords: Joi + .array() + .items(Joi.string()) + .options({ + stripUnknown: { + arrays: true, + }, + }) + .default(() => ([])), + sources: Joi + .array() + .items(Joi.string()) + .options({ + stripUnknown: { + arrays: true, + }, + }) + .default(() => ([])), + urls: Joi + .array() + .items(Joi.string()) + .options({ + stripUnknown: { + arrays: true, + }, + }) + .default(() => ([])), + maintainers: Joi + .array() + .items(helmChartMaintainerValidator) + .options({ + stripUnknown: { + arrays: true, + }, + }) + .default(() => ([])), + dependencies: Joi + .array() + .items(helmChartDependencyValidator) + .options({ + stripUnknown: { + arrays: true, + }, + }) + .default(() => ([])), + annotations: Joi + .object({}) + .pattern(/.*/, Joi.string()) + .default(() => ({})), +}); + +export interface HelmChartCreateOpts { + onError?: "throw" | "log"; +} + +export interface HelmChartMaintainer { + name: string; + email: string; + url?: string; +} + +export interface RawHelmChartDependency { + name: string; + repository: string; + condition?: string; + version: string; + tags?: string[]; +} + +export type HelmChartDependency = Required> + & Pick; + +export interface HelmChartData { + apiVersion: string; + name: string; + version: string; + repo: string; + created: string; + description: string; + keywords: string[]; + sources: string[]; + urls: string[]; + annotations: Record; + dependencies: HelmChartDependency[]; + maintainers: HelmChartMaintainer[]; + deprecated: boolean; + kubeVersion?: string; + digest?: string; + home?: string; + engine?: string; + icon?: string; + appVersion?: string; + type?: string; + tillerVersion?: string; +} + +export class HelmChart implements HelmChartData { + apiVersion: string; + name: string; + version: string; + repo: string; + created: string; + description: string; + keywords: string[]; + sources: string[]; + urls: string[]; + annotations: Record; + dependencies: HelmChartDependency[]; + maintainers: HelmChartMaintainer[]; + deprecated: boolean; + kubeVersion?: string; + digest?: string; + home?: string; + engine?: string; + icon?: string; + appVersion?: string; + type?: string; + tillerVersion?: string; + + private constructor(value: HelmChart | HelmChartData) { + this.apiVersion = value.apiVersion; + this.name = value.name; + this.version = value.version; + this.repo = value.repo; + this.kubeVersion = value.kubeVersion; + this.created = value.created; + this.description = value.description; + this.digest = value.digest; + this.keywords = value.keywords; + this.home = value.home; + this.sources = value.sources; + this.maintainers = value.maintainers; + this.engine = value.engine; + this.icon = value.icon; + this.apiVersion = value.apiVersion; + this.deprecated = value.deprecated; + this.tillerVersion = value.tillerVersion; + this.annotations = value.annotations; + this.urls = value.urls; + this.dependencies = value.dependencies; + this.type = value.type; + + autoBind(this); + } + + static create(data: RawHelmChart): HelmChart; + static create(data: RawHelmChart, opts?: HelmChartCreateOpts): HelmChart | undefined; + static create(data: RawHelmChart, { onError = "throw" }: HelmChartCreateOpts = {}): HelmChart | undefined { + const result = helmChartValidator.validate(data, { + abortEarly: false, + }); + + if (!result.error) { + return new HelmChart(result.value); + } + + const [actualErrors, unknownDetails] = bifurcateArray(result.error.details, ({ type }) => type === "object.unknown"); + + if (unknownDetails.length > 0) { + console.warn("HelmChart data has unexpected fields", { original: data, unknownFields: unknownDetails.flatMap(d => d.path) }); + } + + if (actualErrors.length === 0) { + return new HelmChart(result.value as unknown as HelmChartData); + } + + const validationError = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, result.error._original); + + if (onError === "throw") { + throw validationError; + } + + console.warn("[HELM-CHART]: failed to validate data", data, validationError); + + return undefined; + } + + getId(): string { + const digestPart = this.digest + ? `+${this.digest}` + : ""; + + return `${this.repo}:${this.apiVersion}/${this.name}@${this.getAppVersion()}${digestPart}`; + } + + getName(): string { + return this.name; + } + + getFullName(seperator = "/"): string { + return [this.getRepository(), this.getName()].join(seperator); + } + + getDescription(): string { + return this.description; + } + + getIcon(): string | undefined { + return this.icon; + } + + getHome(): string | undefined { + return this.home; + } + + getMaintainers(): HelmChartMaintainer[] { + return this.maintainers; + } + + getVersion(): string { + return this.version; + } + + getRepository(): string { + return this.repo; + } + + getAppVersion(): string | undefined { + return this.appVersion; + } + + getKeywords(): string[] { + return this.keywords; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-charts.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-charts.injectable.ts new file mode 100644 index 0000000000..4d9bfc55b1 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-charts.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { RawHelmChart } from "../helm-charts.api"; +import { HelmChart } from "../helm-charts.api"; +import { isDefined } from "../../../utils"; +import apiBaseInjectable from "../../api-base.injectable"; + +export type RequestHelmCharts = () => Promise; +export type RepoHelmChartList = Record; + +/** + * Get a list of all helm charts from all saved helm repos + */ +const requestHelmChartsInjectable = getInjectable({ + id: "request-helm-charts", + instantiate: (di) => { + const apiBase = di.inject(apiBaseInjectable); + + return async () => { + const data = await apiBase.get>("/v2/charts"); + + return Object + .values(data) + .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), new Array()) + .map(([chart]) => HelmChart.create(chart, { onError: "log" })) + .filter(isDefined); + }; + }, +}); + +export default requestHelmChartsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-readme.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-readme.injectable.ts new file mode 100644 index 0000000000..fb8eaafa10 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-readme.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AsyncResult } from "../../../utils/async-result"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +const requestReadmeEndpoint = urlBuilderFor("/v2/charts/:repo/:name/readme"); + +export type RequestHelmChartReadme = (repo: string, name: string, version?: string) => Promise>; + +const requestHelmChartReadmeInjectable = getInjectable({ + id: "request-helm-chart-readme", + instantiate: (di): RequestHelmChartReadme => { + const apiBase = di.inject(apiBaseInjectable); + + return (repo, name, version) => ( + apiBase.get(requestReadmeEndpoint.compile({ name, repo }, { version })) + ); + }, +}); + +export default requestHelmChartReadmeInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-values.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-values.injectable.ts new file mode 100644 index 0000000000..71105c9ff9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-values.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { AsyncResult } from "../../../utils/async-result"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +const requestValuesEndpoint = urlBuilderFor("/v2/charts/:repo/:name/values"); + +export type RequestHelmChartValues = (repo: string, name: string, version: string) => Promise>; + +const requestHelmChartValuesInjectable = getInjectable({ + id: "request-helm-chart-values", + instantiate: (di): RequestHelmChartValues => { + const apiBase = di.inject(apiBaseInjectable); + + return (repo, name, version) => ( + apiBase.get(requestValuesEndpoint.compile({ repo, name }, { version })) + ); + }, +}); + +export default requestHelmChartValuesInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-versions.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-versions.injectable.ts new file mode 100644 index 0000000000..ab85594ec6 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-charts.api/request-versions.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import { HelmChart } from "../helm-charts.api"; +import type { RawHelmChart } from "../helm-charts.api"; +import { isDefined } from "../../../utils"; +import apiBaseInjectable from "../../api-base.injectable"; + +const requestVersionsEndpoint = urlBuilderFor("/v2/charts/:repo/:name/versions"); + +export type RequestHelmChartVersions = (repo: string, chartName: string) => Promise; + +const requestHelmChartVersionsInjectable = getInjectable({ + id: "request-helm-chart-versions", + instantiate: (di): RequestHelmChartVersions => { + const apiBase = di.inject(apiBaseInjectable); + + return async (repo, name) => { + const rawVersions = await apiBase.get(requestVersionsEndpoint.compile({ name, repo })) as RawHelmChart[]; + + return rawVersions + .map(version => HelmChart.create(version, { onError: "log" })) + .filter(isDefined); + }; + }, +}); + +export default requestHelmChartVersionsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api.ts new file mode 100644 index 0000000000..3460378e59 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ItemObject } from "../../item.store"; +import type { HelmReleaseDetails } from "./helm-releases.api/request-details.injectable"; + +export interface HelmReleaseUpdateDetails { + log: string; + release: HelmReleaseDetails; +} + +export interface HelmReleaseDto { + appVersion: string; + name: string; + namespace: string; + chart: string; + status: string; + updated: string; + revision: string; +} + +export interface HelmRelease extends HelmReleaseDto, ItemObject { + getNs: () => string; + getChart: (withVersion?: boolean) => string; + getRevision: () => number; + getStatus: () => string; + getVersion: () => string; + getUpdated: (humanize?: boolean, compact?: boolean) => string | number; + getRepo: () => Promise; +} diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.global-override-for-injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.global-override-for-injectable.ts new file mode 100644 index 0000000000..18b3bd0ec0 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../../../test-utils/get-global-override"; +import requestHelmReleaseConfigurationInjectable from "./request-configuration.injectable"; + +export default getGlobalOverride(requestHelmReleaseConfigurationInjectable, () => () => { + throw new Error("Tried to call requestHelmReleaseConfiguration with no override"); +}); diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable.ts new file mode 100644 index 0000000000..e1581c5d76 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +export type RequestHelmReleaseConfiguration = ( + name: string, + namespace: string, + all: boolean +) => Promise; + +const requestConfigurationEnpoint = urlBuilderFor("/v2/releases/:namespace/:name/values"); + +const requestHelmReleaseConfigurationInjectable = getInjectable({ + id: "request-helm-release-configuration", + + instantiate: (di): RequestHelmReleaseConfiguration => { + const apiBase = di.inject(apiBaseInjectable); + + return (name, namespace, all: boolean) => ( + apiBase.get(requestConfigurationEnpoint.compile({ name, namespace }, { all })) + ); + }, +}); + +export default requestHelmReleaseConfigurationInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-create.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-create.injectable.ts new file mode 100644 index 0000000000..c1cd09d40f --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-create.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import yaml from "js-yaml"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { HelmReleaseUpdateDetails } from "../helm-releases.api"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +interface HelmReleaseCreatePayload { + name?: string; + repo: string; + chart: string; + namespace: string; + version: string; + values: string; +} + +export type RequestCreateHelmRelease = (payload: HelmReleaseCreatePayload) => Promise; + +const requestCreateEndpoint = urlBuilderFor("/v2/releases"); + +const requestCreateHelmReleaseInjectable = getInjectable({ + id: "request-create-helm-release", + + instantiate: (di): RequestCreateHelmRelease => { + const apiBase = di.inject(apiBaseInjectable); + + return ({ repo, chart, values, ...data }) => { + return apiBase.post(requestCreateEndpoint.compile({}), { + data: { + chart: `${repo}/${chart}`, + values: yaml.load(values), + ...data, + }, + }); + }; + }, +}); + +export default requestCreateHelmReleaseInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-delete.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-delete.injectable.ts new file mode 100644 index 0000000000..44af4311a9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-delete.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +export type RequestDeleteHelmRelease = (name: string, namespace: string) => Promise; + +const requestDeleteEndpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestDeleteHelmReleaseInjectable = getInjectable({ + id: "request-delete-helm-release", + instantiate: (di): RequestDeleteHelmRelease => { + const apiBase = di.inject(apiBaseInjectable); + + return (name, namespace) => apiBase.del(requestDeleteEndpoint.compile({ name, namespace })); + }, +}); + +export default requestDeleteHelmReleaseInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-details.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-details.injectable.ts new file mode 100644 index 0000000000..37f2287377 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-details.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { KubeJsonApiData } from "../../kube-json-api"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +export interface HelmReleaseDetails { + resources: KubeJsonApiData[]; + name: string; + namespace: string; + version: string; + config: string; // release values + manifest: string; + info: { + deleted: string; + description: string; + first_deployed: string; + last_deployed: string; + notes: string; + status: string; + }; +} + +export type CallForHelmReleaseDetails = (name: string, namespace: string) => Promise; + +const requestDetailsEnpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestHelmReleaseDetailsInjectable = getInjectable({ + id: "call-for-helm-release-details", + + instantiate: (di): CallForHelmReleaseDetails => { + const apiBase = di.inject(apiBaseInjectable); + + return (name, namespace) => apiBase.get(requestDetailsEnpoint.compile({ name, namespace })); + }, +}); + +export default requestHelmReleaseDetailsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-history.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-history.injectable.ts new file mode 100644 index 0000000000..58b6a37dbb --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-history.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +export interface HelmReleaseRevision { + revision: number; + updated: string; + status: string; + chart: string; + app_version: string; + description: string; +} + +export type RequestHelmReleaseHistory = (name: string, namespace: string) => Promise; + +const requestHistoryEnpoint = urlBuilderFor("/v2/releases/:namespace/:name/history"); + +const requestHelmReleaseHistoryInjectable = getInjectable({ + id: "request-helm-release-history", + instantiate: (di): RequestHelmReleaseHistory => { + const apiBase = di.inject(apiBaseInjectable); + + return (name, namespace) => apiBase.get(requestHistoryEnpoint.compile({ name, namespace })); + }, +}); + +export default requestHelmReleaseHistoryInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-releases.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-releases.injectable.ts new file mode 100644 index 0000000000..ee6503ca99 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-releases.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; +import type { HelmReleaseDto } from "../helm-releases.api"; + +export type RequestHelmReleases = (namespace?: string) => Promise; + +const requestHelmReleasesEndpoint = urlBuilderFor("/v2/releases/:namespace?"); + +const requestHelmReleasesInjectable = getInjectable({ + id: "request-helm-releases", + + instantiate: (di): RequestHelmReleases => { + const apiBase = di.inject(apiBaseInjectable); + + return (namespace) => apiBase.get(requestHelmReleasesEndpoint.compile({ namespace })); + }, +}); + +export default requestHelmReleasesInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-rollback.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-rollback.injectable.ts new file mode 100644 index 0000000000..036b399ef2 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-rollback.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +export type RequestHelmReleaseRollback = (name: string, namespace: string, revision: number) => Promise; + +const requestRollbackEndpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestHelmReleaseRollbackInjectable = getInjectable({ + id: "request-helm-release-rollback", + instantiate: (di): RequestHelmReleaseRollback => { + const apiBase = di.inject(apiBaseInjectable); + + return async (name, namespace, revision) => { + await apiBase.put( + requestRollbackEndpoint.compile({ name, namespace }), + { data: { revision }}, + ); + }; + }, +}); + +export default requestHelmReleaseRollbackInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-update.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-update.injectable.ts new file mode 100644 index 0000000000..715a21cea9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-update.injectable.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import type { AsyncResult } from "../../../utils/async-result"; +import apiBaseInjectable from "../../api-base.injectable"; + +interface HelmReleaseUpdatePayload { + repo: string; + chart: string; + version: string; + values: string; +} + +export type RequestHelmReleaseUpdate = ( + name: string, + namespace: string, + payload: HelmReleaseUpdatePayload +) => Promise>; + +const requestUpdateEndpoint = urlBuilderFor("/v2/releases/:namespace/:name"); + +const requestHelmReleaseUpdateInjectable = getInjectable({ + id: "request-helm-release-update", + + instantiate: (di): RequestHelmReleaseUpdate => { + const apiBase = di.inject(apiBaseInjectable); + + return async (name, namespace, { repo, chart, values, version }) => { + try { + await apiBase.put(requestUpdateEndpoint.compile({ name, namespace }), { + data: { + chart: `${repo}/${chart}`, + values, + version, + }, + }); + } catch (e) { + return { callWasSuccessful: false, error: e }; + } + + return { callWasSuccessful: true }; + }; + }, +}); + +export default requestHelmReleaseUpdateInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-values.injectable.ts b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-values.injectable.ts new file mode 100644 index 0000000000..99f1cc17d0 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/helm-releases.api/request-values.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { urlBuilderFor } from "../../../utils/buildUrl"; +import apiBaseInjectable from "../../api-base.injectable"; + +export type RequestHelmReleaseValues = (name: string, namespace: string, all?: boolean) => Promise; + +const requestValuesEndpoint = urlBuilderFor("/v2/release/:namespace/:name/values"); + +const requestHelmReleaseValuesInjectable = getInjectable({ + id: "request-helm-release-values", + instantiate: (di): RequestHelmReleaseValues => { + const apiBase = di.inject(apiBaseInjectable); + + return (name, namespace, all) => apiBase.get(requestValuesEndpoint.compile({ name, namespace }, { all })); + }, +}); + +export default requestHelmReleaseValuesInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts new file mode 100644 index 0000000000..d891e45e0f --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { HorizontalPodAutoscalerApi } from "./horizontal-pod-autoscaler.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const horizontalPodAutoscalerApiInjectable = getInjectable({ + id: "horizontal-pod-autoscaler-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "horizontalPodAutoscalerApi is only available in certain environments"); + + return new HorizontalPodAutoscalerApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default horizontalPodAutoscalerApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts new file mode 100644 index 0000000000..71863d65ad --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts @@ -0,0 +1,313 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { BaseKubeObjectCondition, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { OptionVarient } from "../../utils"; + +export enum HpaMetricType { + Resource = "Resource", + Pods = "Pods", + Object = "Object", + External = "External", + ContainerResource = "ContainerResource", +} + +export interface HorizontalPodAutoscalerMetricTarget { + kind: string; + name: string; + apiVersion: string; +} + +export interface ContainerResourceMetricSource { + container: string; + name: string; + targetAverageUtilization?: number; + targetAverageValue?: string; +} + +export interface ExternalMetricSource { + metricName: string; + metricSelector?: LabelSelector; + targetAverageValue?: string; + targetValue?: string; +} + +export interface ObjectMetricSource { + averageValue?: string; + metricName: string; + selector?: LabelSelector; + target: CrossVersionObjectReference; + targetValue: string; +} + +export interface PodsMetricSource { + metricName: string; + selector?: LabelSelector; + targetAverageValue: string; +} + +export interface ResourceMetricSource { + name: string; + targetAverageUtilization?: number; + targetAverageValue?: string; +} + +export interface BaseHorizontalPodAutoscalerMetricSpec { + containerResource: ContainerResourceMetricSource; + external: ExternalMetricSource; + object: ObjectMetricSource; + pods: PodsMetricSource; + resource: ResourceMetricSource; +} + +export type HorizontalPodAutoscalerMetricSpec = + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient; + +export interface ContainerResourceMetricStatus { + container: string; + currentAverageUtilization?: number; + currentAverageValue: string; + name: string; +} + +export interface ExternalMetricStatus { + currentAverageValue?: string; + currentValue: string; + metricName: string; + metricSelector?: LabelSelector; +} + +export interface ObjectMetricStatus { + averageValue?: string; + currentValue?: string; + metricName: string; + selector?: LabelSelector; + target: CrossVersionObjectReference; +} + +export interface PodsMetricStatus { + currentAverageValue: string; + metricName: string; + selector?: LabelSelector; +} + +export interface ResourceMetricStatus { + currentAverageUtilization?: number; + currentAverageValue: string; + name: string; +} + +export interface BaseHorizontalPodAutoscalerMetricStatus { + containerResource: ContainerResourceMetricStatus; + external: ExternalMetricStatus; + object: ObjectMetricStatus; + pods: PodsMetricStatus; + resource: ResourceMetricStatus; +} + +export type HorizontalPodAutoscalerMetricStatus = + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient; + +export interface CrossVersionObjectReference { + kind: string; + name: string; + apiVersion: string; +} + +export interface HorizontalPodAutoscalerSpec { + scaleTargetRef: CrossVersionObjectReference; + minReplicas?: number; + maxReplicas: number; + metrics?: HorizontalPodAutoscalerMetricSpec[]; +} + +export interface HorizontalPodAutoscalerStatus { + conditions?: BaseKubeObjectCondition[]; + currentReplicas: number; + desiredReplicas: number; + currentMetrics?: HorizontalPodAutoscalerMetricStatus[]; +} + +interface MetricCurrentTarget { + current?: string; + target?: string; +} + +export class HorizontalPodAutoscaler extends KubeObject< + NamespaceScopedMetadata, + HorizontalPodAutoscalerStatus, + HorizontalPodAutoscalerSpec +> { + static readonly kind = "HorizontalPodAutoscaler"; + static readonly namespaced = true; + static readonly apiBase = "/apis/autoscaling/v2beta1/horizontalpodautoscalers"; + + getMaxPods() { + return this.spec.maxReplicas ?? 0; + } + + getMinPods() { + return this.spec.minReplicas ?? 0; + } + + getReplicas() { + return this.status?.currentReplicas ?? 0; + } + + getReadyConditions() { + return this.getConditions().filter(({ isReady }) => isReady); + } + + getConditions() { + return this.status?.conditions?.map(condition => { + const { message, reason, lastTransitionTime, status } = condition; + + return { + ...condition, + isReady: status === "True", + tooltip: `${message || reason} (${lastTransitionTime})`, + }; + }) ?? []; + } + + getMetrics() { + return this.spec.metrics ?? []; + } + + getCurrentMetrics() { + return this.status?.currentMetrics ?? []; + } + + getMetricValues(metric: HorizontalPodAutoscalerMetricSpec): string { + const { + current = "unknown", + target = "unknown", + } = getMetricCurrentTarget(metric, this.getCurrentMetrics()); + + return `${current} / ${target}`; + } +} + +export class HorizontalPodAutoscalerApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + objectConstructor: HorizontalPodAutoscaler, + ...opts ?? {}, + }); + } +} + +function getMetricName(metric: HorizontalPodAutoscalerMetricSpec | HorizontalPodAutoscalerMetricStatus): string | undefined { + switch (metric.type) { + case HpaMetricType.Resource: + return metric.resource.name; + case HpaMetricType.Pods: + return metric.pods.metricName; + case HpaMetricType.Object: + return metric.object.metricName; + case HpaMetricType.External: + return metric.external.metricName; + case HpaMetricType.ContainerResource: + return metric.containerResource.name; + default: + return undefined; + } +} + +function getResourceMetricValue(currentMetric: ResourceMetricStatus | undefined, targetMetric: ResourceMetricSource): MetricCurrentTarget { + return { + current: ( + typeof currentMetric?.currentAverageUtilization === "number" + ? `${currentMetric.currentAverageUtilization}%` + : currentMetric?.currentAverageValue + ), + target: ( + typeof targetMetric?.targetAverageUtilization === "number" + ? `${targetMetric.targetAverageUtilization}%` + : targetMetric?.targetAverageValue + ), + }; +} + +function getPodsMetricValue(currentMetric: PodsMetricStatus | undefined, targetMetric: PodsMetricSource): MetricCurrentTarget { + return { + current: currentMetric?.currentAverageValue, + target: targetMetric?.targetAverageValue, + }; +} + +function getObjectMetricValue(currentMetric: ObjectMetricStatus | undefined, targetMetric: ObjectMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.currentValue + ?? currentMetric?.averageValue + ), + target: ( + targetMetric?.targetValue + ?? targetMetric?.averageValue + ), + }; +} + +function getExternalMetricValue(currentMetric: ExternalMetricStatus | undefined, targetMetric: ExternalMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.currentValue + ?? currentMetric?.currentAverageValue + ), + target: ( + targetMetric?.targetValue + ?? targetMetric?.targetAverageValue + ), + }; +} + +function getContainerResourceMetricValue(currentMetric: ContainerResourceMetricStatus | undefined, targetMetric: ContainerResourceMetricSource): MetricCurrentTarget { + return { + current: ( + typeof currentMetric?.currentAverageUtilization === "number" + ? `${currentMetric.currentAverageUtilization}%` + : currentMetric?.currentAverageValue + ), + target: ( + typeof targetMetric?.targetAverageUtilization === "number" + ? `${targetMetric.targetAverageUtilization}%` + : targetMetric?.targetAverageValue + ), + }; +} + +function getMetricCurrentTarget(spec: HorizontalPodAutoscalerMetricSpec, status: HorizontalPodAutoscalerMetricStatus[]): MetricCurrentTarget { + const currentMetric = status.find(m => ( + m.type === spec.type + && getMetricName(m) === getMetricName(spec) + )); + + switch (spec.type) { + case HpaMetricType.Resource: + return getResourceMetricValue(currentMetric?.resource, spec.resource); + case HpaMetricType.Pods: + return getPodsMetricValue(currentMetric?.pods, spec.pods); + case HpaMetricType.Object: + return getObjectMetricValue(currentMetric?.object, spec.object); + case HpaMetricType.External: + return getExternalMetricValue(currentMetric?.external, spec.external); + case HpaMetricType.ContainerResource: + return getContainerResourceMetricValue(currentMetric?.containerResource, spec.containerResource); + default: + return {}; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/index.ts b/packages/core/src/common/k8s-api/endpoints/index.ts new file mode 100644 index 0000000000..9d3fa94815 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/index.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Kubernetes apis +// Docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/ + +export * from "./cluster.api"; +export * from "./cluster-role.api"; +export * from "./cluster-role-binding.api"; +export * from "./config-map.api"; +export * from "./custom-resource-definition.api"; +export * from "./cron-job.api"; +export * from "./daemon-set.api"; +export * from "./deployment.api"; +export * from "./endpoint.api"; +export * from "./events.api"; +export * from "./horizontal-pod-autoscaler.api"; +export * from "./ingress.api"; +export * from "./ingress-class.api"; +export * from "./job.api"; +export * from "./lease.api"; +export * from "./limit-range.api"; +export * from "./namespace.api"; +export * from "./network-policy.api"; +export * from "./node.api"; +export * from "./persistent-volume.api"; +export * from "./persistent-volume-claim.api"; +export * from "./pod.api"; +export * from "./pod-disruption-budget.api"; +export * from "./pod-metrics.api"; +export * from "./pod-security-policy.api"; +export * from "./priority-class.api"; +export * from "./replica-set.api"; +export * from "./resource-quota.api"; +export * from "./role.api"; +export * from "./role-binding.api"; +export * from "./runtime-class.api"; +export * from "./secret.api"; +export * from "./self-subject-rules-reviews.api"; +export * from "./service.api"; +export * from "./service-account.api"; +export * from "./stateful-set.api"; +export * from "./storage-class.api"; +export * from "./types"; diff --git a/packages/core/src/common/k8s-api/endpoints/ingress-class.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/ingress-class.api.injectable.ts new file mode 100644 index 0000000000..d7d9bf32b9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/ingress-class.api.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { IngressClassApi } from "./ingress-class.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const ingressClassApiInjectable = getInjectable({ + id: "ingress-class-api", + instantiate: (di) => new IngressClassApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }), + + injectionToken: kubeApiInjectionToken, +}); + +export default ingressClassApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/ingress-class.api.ts b/packages/core/src/common/k8s-api/endpoints/ingress-class.api.ts new file mode 100644 index 0000000000..5950192179 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/ingress-class.api.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { KubeApiDependencies, ResourceDescriptor } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export class IngressClassApi extends KubeApi { + constructor(dependencies: KubeApiDependencies) { + super(dependencies, { + objectConstructor: IngressClass, + checkPreferredVersion: true, + fallbackApiBases: ["/apis/extensions/v1beta1/ingressclasses"], + }); + } + + setAsDefault({ name }: ResourceDescriptor, isDefault = true) { + const reqUrl = this.formatUrlForNotListing({ name }); + + return this.request.patch(reqUrl, { + data: { + metadata: { + annotations: { + [IngressClass.ANNOTATION_IS_DEFAULT]: JSON.stringify(isDefault), + }, + }, + }, + }, { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } +} + +// API docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#ingressclass-v1-networking-k8s-io +export type IngressClassMetadata = KubeObjectMetadata & { + "name": string; + "labels"?: { + [name: string]: string | undefined; + "app.kubernetes.io/component"?: "controller"; + }; + "annotations"?: { + [name: string]: string | undefined; + "ingressclass.kubernetes.io/is-default-class"?: "true"; + }; +}; + +export interface IngressClassParametersReference { + "apiGroup": string; // k8s.example.net + "scope": "Namespace" | "Cluster"; + "kind": "ClusterIngressParameter" | "IngressParameter"; + "name": string; // external-config-1 + "namespace"?: string; // namespaced for IngressClass must be defined in `spec.parameters.namespace` instead of `metadata.namespace` (!) +} + +export interface IngressClassSpec { + controller: string; // "example.com/ingress-controller" + parameters?: IngressClassParametersReference; +} + +export interface IngressClassStatus { +} + +export class IngressClass extends KubeObject { + static readonly kind = "IngressClass"; + static readonly namespaced = false; + static readonly apiBase = "/apis/networking.k8s.io/v1/ingressclasses"; + static readonly ANNOTATION_IS_DEFAULT = "ingressclass.kubernetes.io/is-default-class"; + + getController(): string { + return this.spec.controller; + } + + getCtrlApiGroup() { + return this.spec?.parameters?.apiGroup; + } + + getCtrlScope() { + return this.spec?.parameters?.scope; + } + + getCtrlNs() { + return this.spec?.parameters?.namespace; + } + + getCtrlKind() { + return this.spec?.parameters?.kind; + } + + getCtrlName() { + return this.spec?.parameters?.name as string; + } + + get isDefault() { + return this.metadata.annotations?.[IngressClass.ANNOTATION_IS_DEFAULT] === "true"; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/ingress.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/ingress.api.injectable.ts new file mode 100644 index 0000000000..ec25b199b0 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/ingress.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { IngressApi } from "./ingress.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const ingressApiInjectable = getInjectable({ + id: "ingress-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "ingressApi is only available in certain environments"); + + return new IngressApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default ingressApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/ingress.api.ts b/packages/core/src/common/k8s-api/endpoints/ingress.api.ts new file mode 100644 index 0000000000..d35cd77c0b --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/ingress.api.ts @@ -0,0 +1,213 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { NamespaceScopedMetadata, TypedLocalObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { hasTypedProperty, isString, iter } from "../../utils"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { RequireExactlyOne } from "type-fest"; + +export class IngressApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Ingress, + // Add fallback for Kubernetes <1.19 + checkPreferredVersion: true, + fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], + }); + } +} + +export interface ILoadBalancerIngress { + hostname?: string; + ip?: string; +} + +// extensions/v1beta1 +export interface ExtensionsBackend { + serviceName?: string; + servicePort?: number | string; +} + +// networking.k8s.io/v1 +export interface NetworkingBackend { + service?: IngressService; +} + +export type IngressBackend = (ExtensionsBackend | NetworkingBackend) & { + resource?: TypedLocalObjectReference; +}; + +export interface IngressService { + name: string; + port: RequireExactlyOne<{ + name: string; + number: number; + }>; +} + +function isExtensionsBackend(backend: IngressBackend): backend is ExtensionsBackend { + return hasTypedProperty(backend, "serviceName", isString); +} + +/** + * Format an ingress backend into the name of the service and port + * @param backend The ingress target + */ +export function getBackendServiceNamePort(backend: IngressBackend | undefined): string { + if (!backend) { + return ""; + } + + if (isExtensionsBackend(backend)) { + return `${backend.serviceName}:${backend.servicePort}`; + } + + if (backend.service) { + const { name, port } = backend.service; + + return `${name}:${port.number ?? port.name}`; + } + + return ""; +} + +export interface HTTPIngressPath { + pathType: "Exact" | "Prefix" | "ImplementationSpecific"; + path?: string; + backend?: IngressBackend; +} + +export interface HTTPIngressRuleValue { + paths: HTTPIngressPath[]; +} + +export interface IngressRule { + host?: string; + http?: HTTPIngressRuleValue; +} + +export interface IngressSpec { + tls: { + secretName: string; + }[]; + rules?: IngressRule[]; + // extensions/v1beta1 + backend?: ExtensionsBackend; + /** + * The default backend which is exactly on of: + * - service + * - resource + */ + defaultBackend?: RequireExactlyOne; +} + +export interface IngressStatus { + loadBalancer: { + ingress?: ILoadBalancerIngress[]; + }; +} + +export class Ingress extends KubeObject< + NamespaceScopedMetadata, + IngressStatus, + IngressSpec +> { + static readonly kind = "Ingress"; + static readonly namespaced = true; + static readonly apiBase = "/apis/networking.k8s.io/v1/ingresses"; + + getRules() { + return this.spec.rules ?? []; + } + + getRoutes(): string[] { + return computeRouteDeclarations(this).map(({ url, service }) => `${url} ⇢ ${service}`); + } + + getServiceNamePort(): ExtensionsBackend | undefined { + const { spec: { backend, defaultBackend } = {}} = this; + + const serviceName = defaultBackend?.service?.name ?? backend?.serviceName; + const servicePort = defaultBackend?.service?.port.number ?? defaultBackend?.service?.port.name ?? backend?.servicePort; + + if (!serviceName || !servicePort) { + return undefined; + } + + return { + serviceName, + servicePort, + }; + } + + getHosts() { + const { spec: { rules = [] }} = this; + + return [...iter.filterMap(rules, rule => rule.host)]; + } + + getPorts() { + const ports: number[] = []; + const { spec: { tls, rules = [], backend, defaultBackend }} = this; + const httpPort = 80; + const tlsPort = 443; + // Note: not using the port name (string) + const servicePort = defaultBackend?.service?.port.number ?? backend?.servicePort; + + if (rules.length > 0) { + if (rules.some(rule => rule.http)) { + ports.push(httpPort); + } + } else if (servicePort !== undefined) { + ports.push(Number(servicePort)); + } + + if (tls && tls.length > 0) { + ports.push(tlsPort); + } + + return ports.join(", "); + } + + getLoadBalancers() { + return this.status?.loadBalancer?.ingress?.map(address => ( + address.hostname || address.ip + )) ?? []; + } +} + +export interface ComputedIngressRoute { + displayAsLink: boolean; + pathname: string; + url: string; + service: string; +} + +export function computeRuleDeclarations(ingress: Ingress, rule: IngressRule): ComputedIngressRoute[] { + const { host = "*", http: { paths } = { paths: [] }} = rule; + const protocol = (ingress.spec?.tls?.length ?? 0) === 0 + ? "http" + : "https"; + + return paths.map(({ path = "/", backend }) => ({ + displayAsLink: !host.includes("*"), + pathname: path, + url: `${protocol}://${host}${path}`, + service: getBackendServiceNamePort(backend), + })); +} + +export function computeRouteDeclarations(ingress: Ingress): ComputedIngressRoute[] { + return ingress.getRules().flatMap(rule => computeRuleDeclarations(ingress, rule)); +} diff --git a/packages/core/src/common/k8s-api/endpoints/job.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/job.api.injectable.ts new file mode 100644 index 0000000000..923b96a2eb --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/job.api.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { JobApi } from "./job.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const jobApiInjectable = getInjectable({ + id: "job-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "jobApi is only available in certain environments"); + + return new JobApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }, { + checkPreferredVersion: true, + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default jobApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/job.api.ts b/packages/core/src/common/k8s-api/endpoints/job.api.ts new file mode 100644 index 0000000000..ef47419e80 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/job.api.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { PodSpec } from "./pod.api"; +import type { Container } from "./types/container"; +import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; + +export interface JobSpec { + parallelism?: number; + completions?: number; + backoffLimit?: number; + selector?: LabelSelector; + template: { + metadata: { + creationTimestamp?: string; + labels?: Partial>; + annotations?: Partial>; + }; + spec: PodSpec; + }; + containers?: Container[]; + restartPolicy?: string; + terminationGracePeriodSeconds?: number; + dnsPolicy?: string; + serviceAccountName?: string; + serviceAccount?: string; + schedulerName?: string; +} + +export interface JobStatus extends KubeObjectStatus { + startTime: string; + completionTime: string; + succeeded: number; +} + +export class Job extends KubeObject< + NamespaceScopedMetadata, + JobStatus, + JobSpec +> { + static readonly kind = "Job"; + static readonly namespaced = true; + static readonly apiBase = "/apis/batch/v1/jobs"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector?.matchLabels); + } + + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata.labels); + } + + getTolerations() { + return this.spec.template.spec.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } + + getDesiredCompletions() { + return this.spec.completions ?? 0; + } + + getCompletions() { + return this.status?.succeeded ?? 0; + } + + getParallelism() { + return this.spec.parallelism; + } + + getCondition() { + // Type of Job condition could be only Complete or Failed + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#jobcondition-v1-batch + return this.status?.conditions?.find(({ status }) => status === "True"); + } + + getImages() { + return this.spec.template.spec.containers?.map(container => container.image) ?? []; + } +} + +export class JobApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Job, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/lease.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/lease.api.injectable.ts new file mode 100644 index 0000000000..f5d8e73b55 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/lease.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { LeaseApi } from "./lease.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const leaseApiInjectable = getInjectable({ + id: "lease-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "leaseApi is only available in certain environments"); + + return new LeaseApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default leaseApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/lease.api.ts b/packages/core/src/common/k8s-api/endpoints/lease.api.ts new file mode 100644 index 0000000000..25f636dcfb --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/lease.api.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; + +export interface LeaseSpec { + acquireTime?: string; + holderIdentity: string; + leaseDurationSeconds: number; + leaseTransitions?: number; + renewTime: string; +} + +export class Lease extends KubeObject< + NamespaceScopedMetadata, + void, + LeaseSpec +> { + static readonly kind = "Lease"; + static readonly namespaced = true; + static readonly apiBase = "/apis/coordination.k8s.io/v1/leases"; + + getAcquireTime(): string { + return this.spec.acquireTime || ""; + } + + getHolderIdentity(): string { + return this.spec.holderIdentity; + } + + getLeaseDurationSeconds(): number { + return this.spec.leaseDurationSeconds; + } + + getLeaseTransitions(): number | undefined { + return this.spec.leaseTransitions; + } + + getRenewTime(): string { + return this.spec.renewTime; + } +} + +export class LeaseApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Lease, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/limit-range.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/limit-range.api.injectable.ts new file mode 100644 index 0000000000..7f50e2df92 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/limit-range.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { LimitRangeApi } from "./limit-range.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const limitRangeApiInjectable = getInjectable({ + id: "limit-range-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "limitRangeApi is only available in certain environments"); + + return new LimitRangeApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default limitRangeApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/limit-range.api.ts b/packages/core/src/common/k8s-api/endpoints/limit-range.api.ts new file mode 100644 index 0000000000..eec533993c --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/limit-range.api.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export enum LimitType { + CONTAINER = "Container", + POD = "Pod", + PVC = "PersistentVolumeClaim", +} + +export enum Resource { + MEMORY = "memory", + CPU = "cpu", + STORAGE = "storage", + EPHEMERAL_STORAGE = "ephemeral-storage", +} + +export enum LimitPart { + MAX = "max", + MIN = "min", + DEFAULT = "default", + DEFAULT_REQUEST = "defaultRequest", + MAX_LIMIT_REQUEST_RATIO = "maxLimitRequestRatio", +} + +type LimitRangeParts = Partial>>; + +export interface LimitRangeItem extends LimitRangeParts { + type: string; +} + +export interface LimitRangeSpec { + limits: LimitRangeItem[]; +} + +export class LimitRange extends KubeObject< + NamespaceScopedMetadata, + void, + LimitRangeSpec +> { + static readonly kind = "LimitRange"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/limitranges"; + + getContainerLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.CONTAINER); + } + + getPodLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.POD); + } + + getPVCLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.PVC); + } +} + +export class LimitRangeApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + objectConstructor: LimitRange, + ...opts ?? {}, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api.ts new file mode 100644 index 0000000000..406ab1d0b2 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Metrics api + +import moment from "moment"; +import { isDefined, object } from "../../utils"; + +export interface MetricData { + status: string; + data: { + resultType: string; + result: MetricResult[]; + }; +} + +export interface MetricResult { + metric: { + [name: string]: string | undefined; + instance?: string; + node?: string; + pod?: string; + kubernetes?: string; + kubernetes_node?: string; + kubernetes_namespace?: string; + }; + values: [number, string][]; +} + +export function normalizeMetrics(metrics: MetricData | undefined | null, frames = 60): MetricData { + if (!metrics?.data?.result) { + return { + data: { + resultType: "", + result: [{ + metric: {}, + values: [], + }], + }, + status: "", + }; + } + + const { result } = metrics.data; + + if (result.length) { + if (frames > 0) { + // fill the gaps + result.forEach(res => { + if (!res.values || !res.values.length) return; + + let now = moment().startOf("minute").subtract(1, "minute").unix(); + let timestamp = res.values[0][0]; + + while (timestamp <= now) { + timestamp = moment.unix(timestamp).add(1, "minute").unix(); + + if (!res.values.find((value) => value[0] === timestamp)) { + res.values.push([timestamp, "0"]); + } + } + + while (res.values.length < frames) { + const timestamp = moment.unix(res.values[0][0]).subtract(1, "minute").unix(); + + if (!res.values.find((value) => value[0] === timestamp)) { + res.values.unshift([timestamp, "0"]); + } + now = timestamp; + } + }); + } + } + else { + // always return at least empty values array + result.push({ + metric: {}, + values: [], + } as MetricResult); + } + + return metrics; +} + +export function isMetricsEmpty(metrics: Partial>) { + return Object.values(metrics).every(metric => !metric?.data?.result?.length); +} + +export function getItemMetrics(metrics: Partial> | null | undefined, itemName: string): Partial> | undefined { + if (!metrics) { + return undefined; + } + + const itemMetrics = { ...metrics }; + + for (const metric in metrics) { + if (!metrics[metric]?.data?.result) { + continue; + } + const results = metrics[metric]?.data.result; + const result = results?.find(res => Object.values(res.metric)[0] == itemName); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemMetrics[metric]!.data.result = result ? [result] : []; + } + + return itemMetrics; +} + +export function getMetricLastPoints(metrics: Partial>): Partial> { + return object.fromEntries( + object.entries(metrics) + .map(([metricName, metric]) => { + try { + return [metricName, +metric.data.result[0].values.slice(-1)[0][1]] as const; + } catch { + return undefined; + } + }) + .filter(isDefined), + ); +} diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable.ts new file mode 100644 index 0000000000..84268e3f6a --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import type { RequestMetricsParams } from "./request-metrics.injectable"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface ClusterMetricData { + memoryUsage: MetricData; + memoryRequests: MetricData; + memoryLimits: MetricData; + memoryCapacity: MetricData; + memoryAllocatableCapacity: MetricData; + cpuUsage: MetricData; + cpuRequests: MetricData; + cpuLimits: MetricData; + cpuCapacity: MetricData; + cpuAllocatableCapacity: MetricData; + podUsage: MetricData; + podCapacity: MetricData; + podAllocatableCapacity: MetricData; + fsSize: MetricData; + fsUsage: MetricData; +} + +export type RequestClusterMetricsByNodeNames = (nodeNames: string[], params?: RequestMetricsParams) => Promise; + +const requestClusterMetricsByNodeNamesInjectable = getInjectable({ + id: "get-cluster-metrics-by-node-names", + instantiate: (di): RequestClusterMetricsByNodeNames => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (nodeNames, params) => { + const opts = { + category: "cluster", + nodes: nodeNames.join("|"), + }; + + return requestMetrics({ + memoryUsage: opts, + workloadMemoryUsage: opts, + memoryRequests: opts, + memoryLimits: opts, + memoryCapacity: opts, + memoryAllocatableCapacity: opts, + cpuUsage: opts, + cpuRequests: opts, + cpuLimits: opts, + cpuCapacity: opts, + cpuAllocatableCapacity: opts, + podUsage: opts, + podCapacity: opts, + podAllocatableCapacity: opts, + fsSize: opts, + fsUsage: opts, + }, params); + }; + }, +}); + +export default requestClusterMetricsByNodeNamesInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable.ts new file mode 100644 index 0000000000..8167a6bef4 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface IngressMetricData { + bytesSentSuccess: MetricData; + bytesSentFailure: MetricData; + requestDurationSeconds: MetricData; + responseDurationSeconds: MetricData; +} + +export type RequestIngressMetrics = (ingress: string, namespace: string) => Promise; + +const requestIngressMetricsInjectable = getInjectable({ + id: "request-ingress-metrics", + instantiate: (di): RequestIngressMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (ingress, namespace) => { + const opts = { category: "ingress", ingress, namespace }; + + return requestMetrics({ + bytesSentSuccess: opts, + bytesSentFailure: opts, + requestDurationSeconds: opts, + responseDurationSeconds: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestIngressMetricsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable.ts new file mode 100644 index 0000000000..6789d6debc --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface NodeMetricData { + memoryUsage: MetricData; + workloadMemoryUsage: MetricData; + memoryCapacity: MetricData; + memoryAllocatableCapacity: MetricData; + cpuUsage: MetricData; + cpuCapacity: MetricData; + fsUsage: MetricData; + fsSize: MetricData; +} + +export type RequestAllNodeMetrics = () => Promise; + +const requestAllNodeMetricsInjectable = getInjectable({ + id: "request-all-node-metrics", + instantiate: (di): RequestAllNodeMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return () => { + const opts = { category: "nodes" }; + + return requestMetrics({ + memoryUsage: opts, + workloadMemoryUsage: opts, + memoryCapacity: opts, + memoryAllocatableCapacity: opts, + cpuUsage: opts, + cpuCapacity: opts, + fsSize: opts, + fsUsage: opts, + }); + }; + }, +}); + +export default requestAllNodeMetricsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-metrics.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-metrics.injectable.ts new file mode 100644 index 0000000000..e83c52b9aa --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-metrics.injectable.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getSecondsFromUnixEpoch } from "../../../utils/date/get-current-date-time"; +import apiBaseInjectable from "../../api-base.injectable"; +import type { MetricData } from "../metrics.api"; + + +export interface RequestMetricsParams { + /** + * timestamp in seconds or valid date-string + */ + start?: number | string; + + /** + * timestamp in seconds or valid date-string + */ + end?: number | string; + + /** + * step in seconds + * @default 60 (1 minute) + */ + step?: number; + + /** + * time-range in seconds for data aggregation + * @default 3600 (1 hour) + */ + range?: number; + + /** + * rbac-proxy validation param + */ + namespace?: string; +} + +export interface RequestMetrics { + (query: string, params?: RequestMetricsParams): Promise; + (query: string[], params?: RequestMetricsParams): Promise; + (query: Record>>, params?: RequestMetricsParams): Promise>; +} + +const requestMetricsInjectable = getInjectable({ + id: "request-metrics", + instantiate: (di) => { + const apiBase = di.inject(apiBaseInjectable); + + return (async (query: object, params: RequestMetricsParams = {}) => { + const { range = 3600, step = 60, namespace } = params; + let { start, end } = params; + + if (!start && !end) { + const now = getSecondsFromUnixEpoch(); + + start = now - range; + end = now; + } + + return apiBase.post("/metrics", { + data: query, + query: { + start, end, step, + "kubernetes_namespace": namespace, + }, + }); + }) as RequestMetrics; + }, +}); + +export default requestMetricsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable.ts new file mode 100644 index 0000000000..5cafb5ade0 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import type { PersistentVolumeClaim } from "../persistent-volume-claim.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface PersistentVolumeClaimMetricData { + diskUsage: MetricData; + diskCapacity: MetricData; +} + +export type RequestPersistentVolumeClaimMetrics = (claim: PersistentVolumeClaim) => Promise; + +const requestPersistentVolumeClaimMetricsInjectable = getInjectable({ + id: "request-persistent-volume-claim-metrics", + instantiate: (di): RequestPersistentVolumeClaimMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (claim) => { + const opts = { category: "pvc", pvc: claim.getName(), namespace: claim.getNs() }; + + return requestMetrics({ + diskUsage: opts, + diskCapacity: opts, + }, { + namespace: opts.namespace, + }); + }; + }, +}); + +export default requestPersistentVolumeClaimMetricsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable.ts new file mode 100644 index 0000000000..d5653574ff --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { DaemonSet } from "../daemon-set.api"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface DaemonSetPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForDaemonSets = (daemonsets: DaemonSet[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForDaemonSetsInjectable = getInjectable({ + id: "request-pod-metrics-for-daemon-sets", + instantiate: (di): RequestPodMetricsForDaemonSets => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (daemonSets, namespace, selector = "") => { + const podSelector = daemonSets.map(daemonSet => `${daemonSet.getName()}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForDaemonSetsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable.ts new file mode 100644 index 0000000000..ced8ccd7fe --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Deployment } from "../deployment.api"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface DeploymentPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForDeployments = (deployments: Deployment[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForDeploymentsInjectable = getInjectable({ + id: "request-pod-metrics-for-deployments", + instantiate: (di): RequestPodMetricsForDeployments => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (deployments, namespace, selector = "") => { + const podSelector = deployments.map(deployment => `${deployment.getName()}-[[:alnum:]]{9,}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForDeploymentsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable.ts new file mode 100644 index 0000000000..d52f028cf0 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Job } from "../job.api"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface JobPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForJobs = (jobs: Job[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForJobsInjectable = getInjectable({ + id: "request-pod-metrics-for-jobs", + instantiate: (di): RequestPodMetricsForJobs => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (jobs, namespace, selector = "") => { + const podSelector = jobs.map(job => `${job.getName()}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForJobsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable.ts new file mode 100644 index 0000000000..4186cba7b4 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import type { ReplicaSet } from "../replica-set.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface ReplicaSetPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForReplicaSets = (replicaSets: ReplicaSet[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForReplicaSetsInjectable = getInjectable({ + id: "request-pod-metrics-for-replica-sets", + instantiate: (di): RequestPodMetricsForReplicaSets => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (replicaSets, namespace, selector = "") => { + const podSelector = replicaSets.map(replicaSet => `${replicaSet.getName()}-[[:alnum:]]{5}`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForReplicaSetsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable.ts new file mode 100644 index 0000000000..227961bc02 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import type { StatefulSet } from "../stateful-set.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface StatefulSetPodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsForStatefulSets = (statefulSets: StatefulSet[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsForStatefulSetsInjectable = getInjectable({ + id: "request-pod-metrics-for-stateful-sets", + instantiate: (di): RequestPodMetricsForStatefulSets => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (statefulSets, namespace, selector = "") => { + const podSelector = statefulSets.map(statefulset => `${statefulset.getName()}-[[:digit:]]+`).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsForStatefulSetsInjectable; + diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable.ts new file mode 100644 index 0000000000..872ffd740e --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface PodMetricInNamespaceData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; +} + +export type RequestPodMetricsInNamespace = (namespace: string, selector?: string) => Promise; + +const requestPodMetricsInNamespaceInjectable = getInjectable({ + id: "request-pod-metrics-in-namespace", + instantiate: (di): RequestPodMetricsInNamespace => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (namespace, selector) => { + const opts = { category: "pods", pods: ".*", namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + memoryUsage: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsInNamespaceInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable.ts new file mode 100644 index 0000000000..e14e5dc293 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MetricData } from "../metrics.api"; +import type { Pod } from "../pod.api"; +import requestMetricsInjectable from "./request-metrics.injectable"; + +export interface PodMetricData { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; + cpuRequests: MetricData; + cpuLimits: MetricData; + memoryRequests: MetricData; + memoryLimits: MetricData; +} + +export type RequestPodMetrics = (pods: Pod[], namespace: string, selector?: string) => Promise; + +const requestPodMetricsInjectable = getInjectable({ + id: "request-pod-metrics", + instantiate: (di): RequestPodMetrics => { + const requestMetrics = di.inject(requestMetricsInjectable); + + return (pods, namespace, selector = "pod, namespace") => { + const podSelector = pods.map(pod => pod.getName()).join("|"); + const opts = { category: "pods", pods: podSelector, namespace, selector }; + + return requestMetrics({ + cpuUsage: opts, + cpuRequests: opts, + cpuLimits: opts, + memoryUsage: opts, + memoryRequests: opts, + memoryLimits: opts, + fsUsage: opts, + fsWrites: opts, + fsReads: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); + }; + }, +}); + +export default requestPodMetricsInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/metrics.api/request-providers.injectable.ts b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-providers.injectable.ts new file mode 100644 index 0000000000..0c74c4d58d --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/metrics.api/request-providers.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import apiBaseInjectable from "../../api-base.injectable"; + +export interface MetricProviderInfo { + name: string; + id: string; + isConfigurable: boolean; +} + +export type RequestMetricsProviders = () => Promise; + +const requestMetricsProvidersInjectable = getInjectable({ + id: "request-metrics-providers", + instantiate: (di): RequestMetricsProviders => { + const apiBase = di.inject(apiBaseInjectable); + + return () => apiBase.get("/metrics/providers"); + }, +}); + +export default requestMetricsProvidersInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/namespace.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/namespace.api.injectable.ts new file mode 100644 index 0000000000..fe722433c1 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/namespace.api.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { NamespaceApi } from "./namespace.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const namespaceApiInjectable = getInjectable({ + id: "namespace-api", + + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "namespaceApi is only available in certain environments"); + + return new NamespaceApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default namespaceApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/namespace.api.ts b/packages/core/src/common/k8s-api/endpoints/namespace.api.ts new file mode 100644 index 0000000000..3de24d3df9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/namespace.api.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { ClusterScopedMetadata, KubeObjectStatus } from "../kube-object"; +import { KubeObject } from "../kube-object"; + +export enum NamespaceStatusKind { + ACTIVE = "Active", + TERMINATING = "Terminating", +} + +export interface NamespaceSpec { + finalizers?: string[]; +} + +export interface NamespaceStatus extends KubeObjectStatus { + phase?: string; +} + +export class Namespace extends KubeObject< + ClusterScopedMetadata, + NamespaceStatus, + NamespaceSpec +> { + static readonly kind = "Namespace"; + static readonly namespaced = false; + static readonly apiBase = "/api/v1/namespaces"; + + getStatus() { + return this.status?.phase ?? "-"; + } +} + +export class NamespaceApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Namespace, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/network-policy.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/network-policy.api.injectable.ts new file mode 100644 index 0000000000..66c6a95732 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/network-policy.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { NetworkPolicyApi } from "./network-policy.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const networkPolicyApiInjectable = getInjectable({ + id: "network-policy-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "networkPolicyApi is only available in certain environments"); + + return new NetworkPolicyApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default networkPolicyApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/network-policy.api.ts b/packages/core/src/common/k8s-api/endpoints/network-policy.api.ts new file mode 100644 index 0000000000..78520d5539 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/network-policy.api.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export interface IPolicyIpBlock { + cidr: string; + except?: string[]; +} + +/** + * @deprecated Use `LabelSelector` instead + */ +export type IPolicySelector = LabelSelector; + +export interface NetworkPolicyPort { + /** + * The protocol which network traffic must match. + * + * One of: + * - `"TCP"` + * - `"UDP"` + * - `"SCTP"` + * + * @default "TCP" + */ + protocol?: string; + + /** + * The port on the given protocol. This can either be a numerical or named + * port on a pod. If this field is not provided, this matches all port names and + * numbers. + * + * If present, only traffic on the specified protocol AND port will be matched. + */ + port?: number | string; + + /** + * If set, indicates that the range of ports from port to endPort, inclusive, + * should be allowed by the policy. This field cannot be defined if the port field + * is not defined or if the port field is defined as a named (string) port. + * + * The endPort must be equal or greater than port. + */ + endPort?: number; +} + +export interface NetworkPolicyPeer { + /** + * IPBlock defines policy on a particular IPBlock. If this field is set then + * neither of the other fields can be. + */ + ipBlock?: IPolicyIpBlock; + + /** + * Selects Namespaces using cluster-scoped labels. This field follows standard label + * selector semantics; if present but empty, it selects all namespaces. + * + * If PodSelector is also set, then the NetworkPolicyPeer as a whole selects + * the Pods matching PodSelector in the Namespaces selected by NamespaceSelector. + * + * Otherwise it selects all Pods in the Namespaces selected by NamespaceSelector. + */ + namespaceSelector?: LabelSelector; + + /** + * This is a label selector which selects Pods. This field follows standard label + * selector semantics; if present but empty, it selects all pods. + * + * If NamespaceSelector is also set, then the NetworkPolicyPeer as a whole selects + * the Pods matching PodSelector in the Namespaces selected by NamespaceSelector. + * + * Otherwise it selects the Pods matching PodSelector in the policy's own Namespace. + */ + podSelector?: LabelSelector; +} + +export interface IPolicyIngress { + from?: NetworkPolicyPeer[]; + ports?: NetworkPolicyPort[]; +} + +export interface IPolicyEgress { + to?: NetworkPolicyPeer[]; + ports?: NetworkPolicyPort[]; +} + +export type PolicyType = "Ingress" | "Egress"; + +export interface NetworkPolicySpec { + podSelector: LabelSelector; + policyTypes?: PolicyType[]; + ingress?: IPolicyIngress[]; + egress?: IPolicyEgress[]; +} + +export class NetworkPolicy extends KubeObject< + NamespaceScopedMetadata, + void, + NetworkPolicySpec +> { + static readonly kind = "NetworkPolicy"; + static readonly namespaced = true; + static readonly apiBase = "/apis/networking.k8s.io/v1/networkpolicies"; + + getMatchLabels(): string[] { + if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return []; + + return Object + .entries(this.spec.podSelector.matchLabels) + .map(data => data.join(":")); + } + + getTypes(): string[] { + if (!this.spec.policyTypes) return []; + + return this.spec.policyTypes; + } +} + +export class NetworkPolicyApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: NetworkPolicy, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/node.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/node.api.injectable.ts new file mode 100644 index 0000000000..b780d69ee7 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/node.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { NodeApi } from "./node.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const nodeApiInjectable = getInjectable({ + id: "node-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "nodeApi is only available in certain environments"); + + return new NodeApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default nodeApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/node.api.ts b/packages/core/src/common/k8s-api/endpoints/node.api.ts new file mode 100644 index 0000000000..158f359a57 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/node.api.ts @@ -0,0 +1,263 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { BaseKubeObjectCondition, ClusterScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { cpuUnitsToNumber, unitsToBytes, isObject } from "../../../renderer/utils"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import { TypedRegEx } from "typed-regex"; + +export class NodeApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Node, + }); + } +} + +export interface NodeTaint { + key: string; + value?: string; + effect: string; + timeAdded: string; +} + +export function formatNodeTaint(taint: NodeTaint): string { + if (taint.value) { + return `${taint.key}=${taint.value}:${taint.effect}`; + } + + return `${taint.key}:${taint.effect}`; +} + +export interface NodeCondition extends BaseKubeObjectCondition { + /** + * Last time we got an update on a given condition. + */ + lastHeartbeatTime?: string; +} + +/** + * These role label prefixs are the ones that are for master nodes + * + * The `master` label has been deprecated in Kubernetes 1.20, and will be removed in 1.25 so we + * have to also use the newer `control-plane` label + */ +const masterNodeLabels = [ + "master", + "control-plane", +]; + +/** + * This regex is used in the `getRoleLabels()` method bellow, but placed here + * as factoring out regexes is best practice. + */ +const nodeRoleLabelKeyMatcher = TypedRegEx("^.*node-role.kubernetes.io/+(?.+)$"); + +export interface NodeSpec { + podCIDR?: string; + podCIDRs?: string[]; + providerID?: string; + /** + * @deprecated see https://issues.k8s.io/61966 + */ + externalID?: string; + taints?: NodeTaint[]; + unschedulable?: boolean; +} + +export interface NodeAddress { + type: "Hostname" | "ExternalIP" | "InternalIP"; + address: string; +} + +export interface NodeStatusResources extends Partial> { + cpu?: string; + "ephemeral-storage"?: string; + "hugepages-1Gi"?: string; + "hugepages-2Mi"?: string; + memory?: string; + pods?: string; +} + +export interface ConfigMapNodeConfigSource { + kubeletConfigKey: string; + name: string; + namespace: string; + resourceVersion?: string; + uid?: string; +} + +export interface NodeConfigSource { + configMap?: ConfigMapNodeConfigSource; +} + +export interface NodeConfigStatus { + active?: NodeConfigSource; + assigned?: NodeConfigSource; + lastKnownGood?: NodeConfigSource; + error?: string; +} + +export interface DaemonEndpoint { + Port: number; //it must be uppercase for backwards compatibility +} + +export interface NodeDaemonEndpoints { + kubeletEndpoint?: DaemonEndpoint; +} + +export interface ContainerImage { + names?: string[]; + sizeBytes?: number; +} + +export interface NodeSystemInfo { + architecture: string; + bootID: string; + containerRuntimeVersion: string; + kernelVersion: string; + kubeProxyVersion: string; + kubeletVersion: string; + machineID: string; + operatingSystem: string; + osImage: string; + systemUUID: string; +} + +export interface AttachedVolume { + name: string; + devicePath: string; +} + +export interface NodeStatus { + capacity?: NodeStatusResources; + allocatable?: NodeStatusResources; + conditions?: NodeCondition[]; + addresses?: NodeAddress[]; + config?: NodeConfigStatus; + daemonEndpoints?: NodeDaemonEndpoints; + images?: ContainerImage[]; + nodeInfo?: NodeSystemInfo; + phase?: string; + volumesInUse?: string[]; + volumesAttached?: AttachedVolume[]; +} + +export class Node extends KubeObject< + ClusterScopedMetadata, + NodeStatus, + NodeSpec +> { + static readonly kind = "Node"; + static readonly namespaced = false; + static readonly apiBase = "/api/v1/nodes"; + + /** + * Returns the concatination of all current condition types which have a status + * of `"True"` + */ + getNodeConditionText(): string { + if (!this.status?.conditions) { + return ""; + } + + return this.status.conditions + .filter(condition => condition.status === "True") + .map(condition => condition.type) + .join(" "); + } + + getTaints() { + return this.spec.taints || []; + } + + isMasterNode(): boolean { + return this.getRoleLabelItems() + .some(roleLabel => masterNodeLabels.includes(roleLabel)); + } + + getRoleLabelItems(): string[] { + const { labels } = this.metadata; + const roleLabels: string[] = []; + + if (!isObject(labels)) { + return roleLabels; + } + + for (const labelKey of Object.keys(labels)) { + const match = nodeRoleLabelKeyMatcher.match(labelKey); + + if (match?.groups) { + roleLabels.push(match.groups.role); + } + } + + if (typeof labels["kubernetes.io/role"] === "string") { + roleLabels.push(labels["kubernetes.io/role"]); + } + + if (typeof labels["node.kubernetes.io/role"] === "string") { + roleLabels.push(labels["node.kubernetes.io/role"]); + } + + return roleLabels; + } + + getRoleLabels(): string { + return this.getRoleLabelItems().join(", "); + } + + getCpuCapacity() { + if (!this.status?.capacity || !this.status.capacity.cpu) return 0; + + return cpuUnitsToNumber(this.status.capacity.cpu); + } + + getMemoryCapacity() { + if (!this.status?.capacity || !this.status.capacity.memory) return 0; + + return unitsToBytes(this.status.capacity.memory); + } + + getConditions(): NodeCondition[] { + const conditions = this.status?.conditions || []; + + if (this.isUnschedulable()) { + return [{ type: "SchedulingDisabled", status: "True" }, ...conditions]; + } + + return conditions; + } + + getActiveConditions() { + return this.getConditions().filter(c => c.status === "True"); + } + + getWarningConditions() { + const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"]; + + return this.getActiveConditions().filter(condition => { + return !goodConditions.includes(condition.type); + }); + } + + getKubeletVersion() { + return this.status?.nodeInfo?.kubeletVersion ?? ""; + } + + getOperatingSystem(): string { + return this.metadata?.labels?.["kubernetes.io/os"] + || this.metadata?.labels?.["beta.kubernetes.io/os"] + || this.status?.nodeInfo?.operatingSystem + || "linux"; + } + + isUnschedulable() { + return this.spec.unschedulable; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts new file mode 100644 index 0000000000..5d21ba9587 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PersistentVolumeClaimApi } from "./persistent-volume-claim.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const persistentVolumeClaimApiInjectable = getInjectable({ + id: "persistent-volume-claim-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "persistentVolumeClaimApi is only available in certain environments"); + + return new PersistentVolumeClaimApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default persistentVolumeClaimApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts b/packages/core/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts new file mode 100644 index 0000000000..947d9139a3 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/persistent-volume-claim.api.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LabelSelector, NamespaceScopedMetadata, TypedLocalObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { Pod } from "./pod.api"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import { object } from "../../utils"; +import type { ResourceRequirements } from "./types/resource-requirements"; + +export class PersistentVolumeClaimApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: PersistentVolumeClaim, + }); + } +} + +export interface PersistentVolumeClaimSpec { + accessModes?: string[]; + dataSource?: TypedLocalObjectReference; + dataSourceRef?: TypedLocalObjectReference; + resources?: ResourceRequirements; + selector?: LabelSelector; + storageClassName?: string; + volumeMode?: string; + volumeName?: string; +} + +export interface PersistentVolumeClaimStatus { + phase: string; // Pending +} + +export class PersistentVolumeClaim extends KubeObject< + NamespaceScopedMetadata, + PersistentVolumeClaimStatus, + PersistentVolumeClaimSpec +> { + static readonly kind = "PersistentVolumeClaim"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/persistentvolumeclaims"; + + getPods(pods: Pod[]): Pod[] { + return pods + .filter(pod => pod.getNs() === this.getNs()) + .filter(pod => ( + pod.getVolumes() + .filter(volume => volume.persistentVolumeClaim?.claimName === this.getName()) + .length > 0 + )); + } + + getStorage(): string { + return this.spec.resources?.requests?.storage ?? "-"; + } + + getMatchLabels(): string[] { + return object.entries(this.spec.selector?.matchLabels) + .map(([name, val]) => `${name}:${val}`); + } + + getMatchExpressions() { + return this.spec.selector?.matchExpressions ?? []; + } + + getStatus(): string { + return this.status?.phase ?? "-"; + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts new file mode 100644 index 0000000000..cde587942c --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PersistentVolumeApi } from "./persistent-volume.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const persistentVolumeApiInjectable = getInjectable({ + id: "persistent-volume-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "persistentVolumeApi is only available in certain environments"); + + return new PersistentVolumeApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default persistentVolumeApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/persistent-volume.api.ts b/packages/core/src/common/k8s-api/endpoints/persistent-volume.api.ts new file mode 100644 index 0000000000..f4d80a82eb --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/persistent-volume.api.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ClusterScopedMetadata, LabelSelector, ObjectReference, TypedLocalObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { unitsToBytes } from "../../utils"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { ResourceRequirements } from "./types/resource-requirements"; + +export interface PersistentVolumeSpec { + /** + * AccessModes contains the desired access modes the volume should have. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + */ + accessModes?: string[]; + dataSource?: TypedLocalObjectReference; + dataSourceRef?: TypedLocalObjectReference; + resources?: ResourceRequirements; + selector?: LabelSelector; + + /** + * Name of the StorageClass required by the claim. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + */ + storageClassName?: string; + + /** + * Defines what type of volume is required by the claim. Value of Filesystem is implied when not + * included in claim spec. + */ + volumeMode?: string; + + /** + * A description of the persistent volume\'s resources and capacity. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#capacity + */ + capacity?: Partial>; + flexVolume?: { + driver: string; // ceph.rook.io/rook-ceph-system, + options?: { + clusterNamespace: string; // rook-ceph, + image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb, + pool: string; // replicapool, + storageClass: string; // rook-ceph-block + }; + }; + mountOptions?: string[]; + claimRef?: ObjectReference; + persistentVolumeReclaimPolicy?: string; // Delete, + nfs?: { + path: string; + server: string; + }; +} + +export interface PersistentVolumeStatus { + phase: string; + reason?: string; +} + +export class PersistentVolume extends KubeObject< + ClusterScopedMetadata, + PersistentVolumeStatus, + PersistentVolumeSpec +> { + static kind = "PersistentVolume"; + static namespaced = false; + static apiBase = "/api/v1/persistentvolumes"; + + getCapacity(inBytes = false) { + const capacity = this.spec.capacity; + + if (capacity?.storage) { + if (inBytes) return unitsToBytes(capacity.storage); + + return capacity.storage; + } + + return 0; + } + + getStatus() { + return this.status?.phase || "-"; + } + + getStorageClass(): string { + return this.spec.storageClassName ?? ""; + } + + getClaimRefName(): string { + return this.spec.claimRef?.name ?? ""; + } + + getStorageClassName() { + return this.spec.storageClassName || ""; + } +} + +export class PersistentVolumeApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: PersistentVolume, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts new file mode 100644 index 0000000000..28ecff01b1 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodDisruptionBudgetApi } from "./pod-disruption-budget.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const podDisruptionBudgetApiInjectable = getInjectable({ + id: "pod-disruption-budget-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podDisruptionBudgetApi is only available in certain environments"); + + return new PodDisruptionBudgetApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default podDisruptionBudgetApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts new file mode 100644 index 0000000000..0010bbb642 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export interface PodDisruptionBudgetSpec { + minAvailable: string; + maxUnavailable: string; + selector: LabelSelector; +} + +export interface PodDisruptionBudgetStatus { + currentHealthy: number; + desiredHealthy: number; + disruptionsAllowed: number; + expectedPods: number; +} + +export class PodDisruptionBudget extends KubeObject< + NamespaceScopedMetadata, + PodDisruptionBudgetStatus, + PodDisruptionBudgetSpec +> { + static readonly kind = "PodDisruptionBudget"; + static readonly namespaced = true; + static readonly apiBase = "/apis/policy/v1beta1/poddisruptionbudgets"; + + getSelectors() { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); + } + + getMinAvailable() { + return this.spec.minAvailable || "N/A"; + } + + getMaxUnavailable() { + return this.spec.maxUnavailable || "N/A"; + } + + getCurrentHealthy() { + return this.status?.currentHealthy ?? 0; + } + + getDesiredHealthy() { + return this.status?.desiredHealthy ?? 0; + } +} + +export class PodDisruptionBudgetApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: PodDisruptionBudget, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts new file mode 100644 index 0000000000..ddd2b109ef --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodMetricsApi } from "./pod-metrics.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const podMetricsApiInjectable = getInjectable({ + id: "pod-metrics-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podMetricsApi is only available in certain environments"); + + return new PodMetricsApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default podMetricsApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/pod-metrics.api.ts b/packages/core/src/common/k8s-api/endpoints/pod-metrics.api.ts new file mode 100644 index 0000000000..84a58026d5 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod-metrics.api.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; + +export interface PodMetricsData extends KubeJsonApiData, void, void> { + timestamp: string; + window: string; + containers: PodMetricsContainer[]; +} + +export interface PodMetricsContainerUsage { + cpu: string; + memory: string; +} + +export interface PodMetricsContainer { + name: string; + usage: PodMetricsContainerUsage; +} + +export class PodMetrics extends KubeObject< + NamespaceScopedMetadata, + void, + void +> { + static readonly kind = "PodMetrics"; + static readonly namespaced = true; + static readonly apiBase = "/apis/metrics.k8s.io/v1beta1/pods"; + + timestamp: string; + window: string; + containers: PodMetricsContainer[]; + + constructor({ + timestamp, + window, + containers, + ...rest + }: PodMetricsData) { + super(rest); + this.timestamp = timestamp; + this.window = window; + this.containers = containers; + } +} + +export class PodMetricsApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: PodMetrics, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts new file mode 100644 index 0000000000..c62b568e87 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodSecurityPolicyApi } from "./pod-security-policy.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const podSecurityPolicyApiInjectable = getInjectable({ + id: "pod-security-policy-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podSecurityPolicyApi is only available in certain environments"); + + return new PodSecurityPolicyApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default podSecurityPolicyApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/pod-security-policy.api.ts b/packages/core/src/common/k8s-api/endpoints/pod-security-policy.api.ts new file mode 100644 index 0000000000..f0e9e1110c --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod-security-policy.api.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ClusterScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export interface PodSecurityPolicySpec { + allowPrivilegeEscalation?: boolean; + allowedCSIDrivers?: { + name: string; + }[]; + allowedCapabilities: string[]; + allowedFlexVolumes?: { + driver: string; + }[]; + allowedHostPaths?: { + pathPrefix: string; + readOnly: boolean; + }[]; + allowedProcMountTypes?: string[]; + allowedUnsafeSysctls?: string[]; + defaultAddCapabilities?: string[]; + defaultAllowPrivilegeEscalation?: boolean; + forbiddenSysctls?: string[]; + fsGroup?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + hostIPC?: boolean; + hostNetwork?: boolean; + hostPID?: boolean; + hostPorts?: { + max: number; + min: number; + }[]; + privileged?: boolean; + readOnlyRootFilesystem?: boolean; + requiredDropCapabilities?: string[]; + runAsGroup?: { + ranges: { + max: number; + min: number; + }[]; + rule: string; + }; + runAsUser?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + runtimeClass?: { + allowedRuntimeClassNames: string[]; + defaultRuntimeClassName: string; + }; + seLinux?: { + rule: string; + seLinuxOptions: { + level: string; + role: string; + type: string; + user: string; + }; + }; + supplementalGroups?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + volumes?: string[]; +} + +export class PodSecurityPolicy extends KubeObject< + ClusterScopedMetadata, + void, + PodSecurityPolicySpec +> { + static readonly kind = "PodSecurityPolicy"; + static readonly namespaced = false; + static readonly apiBase = "/apis/policy/v1beta1/podsecuritypolicies"; + + isPrivileged() { + return !!this.spec.privileged; + } + + getVolumes() { + return this.spec.volumes || []; + } + + getRules() { + const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec; + + return { + fsGroup: fsGroup ? fsGroup.rule : "", + runAsGroup: runAsGroup ? runAsGroup.rule : "", + runAsUser: runAsUser ? runAsUser.rule : "", + supplementalGroups: supplementalGroups ? supplementalGroups.rule : "", + seLinux: seLinux ? seLinux.rule : "", + }; + } +} + +export class PodSecurityPolicyApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: PodSecurityPolicy, + + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/pod.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/pod.api.injectable.ts new file mode 100644 index 0000000000..f2c1635b14 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod.api.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PodApi } from "./pod.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const podApiInjectable = getInjectable({ + id: "pod-api", + + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "podApi is only available in certain environments"); + + return new PodApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default podApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/pod.api.ts b/packages/core/src/common/k8s-api/endpoints/pod.api.ts new file mode 100644 index 0000000000..3a0bed57cd --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/pod.api.ts @@ -0,0 +1,853 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies, ResourceDescriptor } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { RequireExactlyOne } from "type-fest"; +import type { KubeObjectMetadata, LocalObjectReference, Affinity, Toleration, NamespaceScopedMetadata } from "../kube-object"; +import type { SecretReference } from "./secret.api"; +import type { PersistentVolumeClaimSpec } from "./persistent-volume-claim.api"; +import { KubeObject } from "../kube-object"; +import { isDefined } from "../../utils"; +import type { PodSecurityContext } from "./types/pod-security-context"; +import type { Probe } from "./types/probe"; +import type { Container } from "./types/container"; +import type { ObjectFieldSelector, ResourceFieldSelector } from "./types"; + +export class PodApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: Pod, + }); + } + + async getLogs(params: ResourceDescriptor, query?: PodLogsQuery): Promise { + const path = `${this.getUrl(params)}/log`; + + return this.request.get(path, { query }); + } +} + +// Reference: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#read-log-pod-v1-core +export interface PodLogsQuery { + container?: string; + tailLines?: number; + timestamps?: boolean; + sinceTime?: string; // Date.toISOString()-format + follow?: boolean; + previous?: boolean; +} + +export enum PodStatusPhase { + TERMINATED = "Terminated", + FAILED = "Failed", + PENDING = "Pending", + RUNNING = "Running", + SUCCEEDED = "Succeeded", + EVICTED = "Evicted", +} + +export interface ContainerStateRunning { + startedAt: string; +} + +export interface ContainerStateWaiting { + reason: string; + message: string; +} + +export interface ContainerStateTerminated { + startedAt: string; + finishedAt: string; + exitCode: number; + reason: string; + containerID?: string; + message?: string; + signal?: number; +} + +/** + * ContainerState holds a possible state of container. Only one of its members + * may be specified. If none of them is specified, the default one is + * `ContainerStateWaiting`. + */ +export interface ContainerState { + running?: ContainerStateRunning; + waiting?: ContainerStateWaiting; + terminated?: ContainerStateTerminated; +} + +export interface PodContainerStatus { + name: string; + state?: ContainerState; + lastState?: ContainerState; + ready: boolean; + restartCount: number; + image: string; + imageID: string; + containerID?: string; + started?: boolean; +} + +export interface AwsElasticBlockStoreSource { + volumeID: string; + fsType: string; +} + +export interface AzureDiskSource { + /** + * The name of the VHD blob object OR the name of an Azure managed data disk if `kind` is `"Managed"`. + */ + diskName: string; + /** + * The URI of the vhd blob object OR the `resourceID` of an Azure managed data disk if `kind` is `"Managed"`. + */ + diskURI: string; + /** + * Kind of disk + * @default "Shared" + */ + kind?: "Shared" | "Dedicated" | "Managed"; + /** + * Disk caching mode. + * @default "None" + */ + cachingMode?: "None" | "ReadOnly" | "ReadWrite"; + /** + * The filesystem type to mount. + * @default "ext4" + */ + fsType?: string; + /** + * Whether the filesystem is used as readOnly. + * @default false + */ + readonly?: boolean; +} + +export interface AzureFileSource { + /** + * The name of the secret that contains both Azure storage account name and key. + */ + secretName: string; + /** + * The share name to be used. + */ + shareName: string; + /** + * In case the secret is stored in a different namespace. + * @default "default" + */ + secretNamespace?: string; + /** + * Whether the filesystem is used as readOnly. + */ + readOnly: boolean; +} + +export interface CephfsSource { + /** + * List of Ceph monitors + */ + monitors: string[]; + /** + * Used as the mounted root, rather than the full Ceph tree. + * @default "/" + */ + path?: string; + /** + * The RADOS user name. + * @default "admin" + */ + user?: string; + /** + * The path to the keyring file. + * @default "/etc/ceph/user.secret" + */ + secretFile?: string; + /** + * Reference to Ceph authentication secrets. If provided, then the secret overrides `secretFile` + */ + secretRef?: SecretReference; + /** + * Whether the filesystem is used as readOnly. + * + * @default false + */ + readOnly?: boolean; +} + +export interface CinderSource { + volumeID: string; + fsType: string; + /** + * @default false + */ + readOnly?: boolean; + secretRef?: SecretReference; +} + +export interface ConfigMapSource { + name: string; + items: { + key: string; + path: string; + }[]; +} + +export interface DownwardApiSource { + items: { + path: string; + fieldRef: { + fieldPath: string; + }; + }[]; +} + +export interface EphemeralSource { + volumeClaimTemplate: { + /** + * All the rest of the fields are ignored and rejected during validation + */ + metadata?: Pick; + spec: PersistentVolumeClaimSpec; + }; +} + +export interface EmptyDirSource { + medium?: string; + sizeLimit?: string; +} + +export interface FiberChannelSource { + /** + * A list of World Wide Names + */ + targetWWNs: string[]; + /** + * Logical Unit number + */ + lun: number; + /** + * The type of filesystem + * @default "ext4" + */ + fsType?: string; + readOnly: boolean; +} + +export interface FlockerSource { + datasetName: string; +} + +export interface FlexVolumeSource { + driver: string; + fsType?: string; + secretRef?: LocalObjectReference; + /** + * @default false + */ + readOnly?: boolean; + options?: Record; +} + +export interface GcePersistentDiskSource { + pdName: string; + fsType: string; +} + +export interface GitRepoSource { + repository: string; + revision: string; +} + +export interface GlusterFsSource { + /** + * The name of the Endpoints object that represents a Gluster cluster configuration. + */ + endpoints: string; + /** + * The Glusterfs volume name. + */ + path: string; + /** + * The boolean that sets the mountpoint readOnly or readWrite. + */ + readOnly: boolean; +} + +export interface HostPathSource { + path: string; + /** + * Determines the sorts of checks that will be done + * @default "" + */ + type?: "" | "DirectoryOrCreate" | "Directory" | "FileOrCreate" | "File" | "Socket" | "CharDevice" | "BlockDevice"; +} + +export interface IScsiSource { + targetPortal: string; + iqn: string; + lun: number; + fsType: string; + readOnly: boolean; + chapAuthDiscovery?: boolean; + chapAuthSession?: boolean; + secretRef?: SecretReference; +} + +export interface LocalSource { + path: string; +} + +export interface NetworkFsSource { + server: string; + path: string; + readOnly?: boolean; +} + +export interface PersistentVolumeClaimSource { + claimName: string; +} + +export interface PhotonPersistentDiskSource { + pdID: string; + /** + * @default "ext4" + */ + fsType?: string; +} + +export interface PortworxVolumeSource { + volumeID: string; + fsType?: string; + readOnly?: boolean; +} + +export interface KeyToPath { + key: string; + path: string; + mode?: number; +} + +export interface ConfigMapProjection { + name: string; + items?: KeyToPath[]; + optional?: boolean; +} + +export interface DownwardAPIVolumeFile { + path: string; + fieldRef?: ObjectFieldSelector; + resourceFieldRef?: ResourceFieldSelector; + mode?: number; +} + +export interface DownwardAPIProjection { + items?: DownwardAPIVolumeFile[]; +} + +export interface SecretProjection { + name: string; + items?: KeyToPath[]; + optional?: boolean; +} + +export interface ServiceAccountTokenProjection { + audience?: string; + expirationSeconds?: number; + path: string; +} + +export interface VolumeProjection { + secret?: SecretProjection; + downwardAPI?: DownwardAPIProjection; + configMap?: ConfigMapProjection; + serviceAccountToken?: ServiceAccountTokenProjection; +} + +export interface ProjectedSource { + sources?: VolumeProjection[]; + defaultMode?: number; +} + +export interface QuobyteSource { + registry: string; + volume: string; + /** + * @default false + */ + readOnly?: boolean; + /** + * @default "serivceaccount" + */ + user?: string; + group?: string; + tenant?: string; +} + +export interface RadosBlockDeviceSource { + monitors: string[]; + image: string; + /** + * @default "ext4" + */ + fsType?: string; + /** + * @default "rbd" + */ + pool?: string; + /** + * @default "admin" + */ + user?: string; + /** + * @default "/etc/ceph/keyring" + */ + keyring?: string; + secretRef?: SecretReference; + /** + * @default false + */ + readOnly?: boolean; +} + +export interface ScaleIoSource { + gateway: string; + system: string; + secretRef?: LocalObjectReference; + /** + * @default false + */ + sslEnabled?: boolean; + protectionDomain?: string; + storagePool?: string; + /** + * @default "ThinProvisioned" + */ + storageMode?: "ThickProvisioned" | "ThinProvisioned"; + volumeName: string; + /** + * @default "xfs" + */ + fsType?: string; + /** + * @default false + */ + readOnly?: boolean; +} + +export interface SecretSource { + secretName: string; + items?: { + key: string; + path: string; + mode?: number; + }[]; + defaultMode?: number; + optional?: boolean; +} + +export interface StorageOsSource { + volumeName: string; + /** + * @default Pod.metadata.namespace + */ + volumeNamespace?: string; + /** + * @default "ext4" + */ + fsType?: string; + /** + * @default false + */ + readOnly?: boolean; + secretRef?: LocalObjectReference; +} + +export interface VsphereVolumeSource { + volumePath: string; + /** + * @default "ext4" + */ + fsType?: string; + storagePolicyName?: string; + storagePolicyID?: string; +} + +export interface ContainerStorageInterfaceSource { + driver: string; + /** + * @default false + */ + readOnly?: boolean; + /** + * @default "ext4" + */ + fsType?: string; + volumeAttributes?: Record; + controllerPublishSecretRef?: SecretReference; + nodeStageSecretRef?: SecretReference; + nodePublishSecretRef?: SecretReference; + controllerExpandSecretRef?: SecretReference; +} + +export interface PodVolumeVariants { + awsElasticBlockStore: AwsElasticBlockStoreSource; + azureDisk: AzureDiskSource; + azureFile: AzureFileSource; + cephfs: CephfsSource; + cinder: CinderSource; + configMap: ConfigMapSource; + csi: ContainerStorageInterfaceSource; + downwardAPI: DownwardApiSource; + emptyDir: EmptyDirSource; + ephemeral: EphemeralSource; + fc: FiberChannelSource; + flexVolume: FlexVolumeSource; + flocker: FlockerSource; + gcePersistentDisk: GcePersistentDiskSource; + gitRepo: GitRepoSource; + glusterfs: GlusterFsSource; + hostPath: HostPathSource; + iscsi: IScsiSource; + local: LocalSource; + nfs: NetworkFsSource; + persistentVolumeClaim: PersistentVolumeClaimSource; + photonPersistentDisk: PhotonPersistentDiskSource; + portworxVolume: PortworxVolumeSource; + projected: ProjectedSource; + quobyte: QuobyteSource; + rbd: RadosBlockDeviceSource; + scaleIO: ScaleIoSource; + secret: SecretSource; + storageos: StorageOsSource; + vsphereVolume: VsphereVolumeSource; +} + +/** + * The valid kinds of volume + */ +export type PodVolumeKind = keyof PodVolumeVariants; + +export type PodSpecVolume = RequireExactlyOne & { + name: string; +}; + + +export interface HostAlias { + ip: string; + hostnames: string[]; +} + +export interface Sysctl { + name: string; + value: string; +} + +export interface TopologySpreadConstraint { + +} + +export interface PodSpec { + activeDeadlineSeconds?: number; + affinity?: Affinity; + automountServiceAccountToken?: boolean; + containers?: Container[]; + dnsPolicy?: string; + enableServiceLinks?: boolean; + ephemeralContainers?: unknown[]; + hostAliases?: HostAlias[]; + hostIPC?: boolean; + hostname?: string; + hostNetwork?: boolean; + hostPID?: boolean; + imagePullSecrets?: LocalObjectReference[]; + initContainers?: Container[]; + nodeName?: string; + nodeSelector?: Partial>; + overhead?: Partial>; + preemptionPolicy?: string; + priority?: number; + priorityClassName?: string; + readinessGates?: unknown[]; + restartPolicy?: string; + runtimeClassName?: string; + schedulerName?: string; + securityContext?: PodSecurityContext; + serviceAccount?: string; + serviceAccountName?: string; + setHostnameAsFQDN?: boolean; + shareProcessNamespace?: boolean; + subdomain?: string; + terminationGracePeriodSeconds?: number; + tolerations?: Toleration[]; + topologySpreadConstraints?: TopologySpreadConstraint[]; + volumes?: PodSpecVolume[]; +} + +export interface PodCondition { + lastProbeTime?: number; + lastTransitionTime?: string; + message?: string; + reason?: string; + type: string; + status: string; +} + +export interface PodStatus { + phase: string; + conditions: PodCondition[]; + hostIP: string; + podIP: string; + podIPs?: { + ip: string; + }[]; + startTime: string; + initContainerStatuses?: PodContainerStatus[]; + containerStatuses?: PodContainerStatus[]; + qosClass?: string; + reason?: string; +} + +export class Pod extends KubeObject< + NamespaceScopedMetadata, + PodStatus, + PodSpec +> { + static kind = "Pod"; + static namespaced = true; + static apiBase = "/api/v1/pods"; + + getAffinityNumber() { + return Object.keys(this.getAffinity()).length; + } + + getInitContainers() { + return this.spec?.initContainers ?? []; + } + + getContainers() { + return this.spec?.containers ?? []; + } + + getAllContainers() { + return [...this.getContainers(), ...this.getInitContainers()]; + } + + getRunningContainers() { + const runningContainerNames = new Set( + this.getContainerStatuses() + .filter(({ state }) => state?.running) + .map(({ name }) => name), + ); + + return this.getAllContainers() + .filter(({ name }) => runningContainerNames.has(name)); + } + + getContainerStatuses(includeInitContainers = true) { + const { containerStatuses = [], initContainerStatuses = [] } = this.status ?? {}; + + if (includeInitContainers) { + return [...containerStatuses, ...initContainerStatuses]; + } + + return [...containerStatuses]; + } + + getRestartsCount(): number { + const { containerStatuses = [] } = this.status ?? {}; + + return containerStatuses.reduce((totalCount, { restartCount }) => totalCount + restartCount, 0); + } + + getQosClass() { + return this.status?.qosClass || ""; + } + + getReason() { + return this.status?.reason || ""; + } + + getPriorityClassName() { + return this.spec?.priorityClassName || ""; + } + + getRuntimeClassName() { + return this.spec?.runtimeClassName || ""; + } + + getServiceAccountName() { + return this.spec?.serviceAccountName || ""; + } + + getStatus(): PodStatusPhase { + const phase = this.getStatusPhase(); + const reason = this.getReason(); + const trueConditionTypes = new Set(this.getConditions() + .filter(({ status }) => status === "True") + .map(({ type }) => type)); + const isInGoodCondition = ["Initialized", "Ready"].every(condition => trueConditionTypes.has(condition)); + + if (reason === PodStatusPhase.EVICTED) { + return PodStatusPhase.EVICTED; + } + + if (phase === PodStatusPhase.FAILED) { + return PodStatusPhase.FAILED; + } + + if (phase === PodStatusPhase.SUCCEEDED) { + return PodStatusPhase.SUCCEEDED; + } + + if (phase === PodStatusPhase.RUNNING && isInGoodCondition) { + return PodStatusPhase.RUNNING; + } + + return PodStatusPhase.PENDING; + } + + // Returns pod phase or container error if occurred + getStatusMessage(): string { + if (this.getReason() === PodStatusPhase.EVICTED) { + return "Evicted"; + } + + if (this.metadata.deletionTimestamp) { + return "Terminating"; + } + + return this.getStatusPhase() || "Waiting"; + } + + getStatusPhase() { + return this.status?.phase; + } + + getConditions() { + return this.status?.conditions ?? []; + } + + getVolumes() { + return this.spec?.volumes ?? []; + } + + getSecrets(): string[] { + return this.getVolumes() + .map(vol => vol.secret?.secretName) + .filter(isDefined); + } + + getNodeSelectors(): string[] { + return Object.entries(this.spec?.nodeSelector ?? {}) + .map(values => values.join(": ")); + } + + getTolerations() { + return this.spec?.tolerations ?? []; + } + + getAffinity(): Affinity { + return this.spec?.affinity ?? {}; + } + + hasIssues() { + for (const { type, status } of this.getConditions()) { + if (type === "Ready" && status !== "True") { + return true; + } + } + + for (const { state } of this.getContainerStatuses()) { + if (state?.waiting?.reason === "CrashLookBackOff") { + return true; + } + } + + return this.getStatusPhase() !== "Running"; + } + + getLivenessProbe(container: Container) { + return this.getProbe(container, container.livenessProbe); + } + + getReadinessProbe(container: Container) { + return this.getProbe(container, container.readinessProbe); + } + + getStartupProbe(container: Container) { + return this.getProbe(container, container.startupProbe); + } + + private getProbe(container: Container, probe: Probe | undefined): string[] { + const probeItems: string[] = []; + + if (!probe) { + return probeItems; + } + + const { + httpGet, + exec, + tcpSocket, + initialDelaySeconds = 0, + timeoutSeconds = 0, + periodSeconds = 0, + successThreshold = 0, + failureThreshold = 0, + } = probe; + + // HTTP Request + if (httpGet) { + const { path = "", port, host = "", scheme = "HTTP" } = httpGet; + const resolvedPort = typeof port === "number" + ? port + // Try and find the port number associated witht the name or fallback to the name itself + : container.ports?.find(containerPort => containerPort.name === port)?.containerPort || port; + + probeItems.push( + "http-get", + `${scheme.toLowerCase()}://${host}:${resolvedPort}${path}`, + ); + } + + // Command + if (exec?.command) { + probeItems.push(`exec [${exec.command.join(" ")}]`); + } + + // TCP Probe + if (tcpSocket?.port) { + probeItems.push(`tcp-socket :${tcpSocket.port}`); + } + + probeItems.push( + `delay=${initialDelaySeconds}s`, + `timeout=${timeoutSeconds}s`, + `period=${periodSeconds}s`, + `#success=${successThreshold}`, + `#failure=${failureThreshold}`, + ); + + return probeItems; + } + + getNodeName(): string | undefined { + return this.spec?.nodeName; + } + + getSelectedNodeOs(): string | undefined { + return this.spec?.nodeSelector?.["kubernetes.io/os"] || this.spec?.nodeSelector?.["beta.kubernetes.io/os"]; + } + + getIPs(): string[] { + const podIPs = this.status?.podIPs ?? []; + + return podIPs.map(value => value.ip); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/priority-class.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/priority-class.api.injectable.ts new file mode 100644 index 0000000000..e21a85a290 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/priority-class.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { PriorityClassApi } from "./priority-class.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const priorityClassApiInjectable = getInjectable({ + id: "priority-class-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "PriorityClassApi is only available in certain environments"); + + return new PriorityClassApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default priorityClassApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/priority-class.api.ts b/packages/core/src/common/k8s-api/endpoints/priority-class.api.ts new file mode 100644 index 0000000000..1d181b6038 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/priority-class.api.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { ClusterScopedMetadata, KubeObjectMetadata, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PreemptionPolicy } from "./types/preemption-policy"; + +export interface PriorityClassData extends KubeJsonApiData, void, void> { + description?: string; + globalDefault?: boolean; + preemptionPolicy?: PreemptionPolicy; + value: number; +} + +export class PriorityClass extends KubeObject< + ClusterScopedMetadata, + void, + void +> { + static readonly kind = "PriorityClass"; + static readonly namespaced = false; + static readonly apiBase = "/apis/scheduling.k8s.io/v1/priorityclasses"; + + description?: string; + globalDefault?: boolean; + preemptionPolicy?: PreemptionPolicy; + value?: number; + + constructor({ description, globalDefault, preemptionPolicy, value, ...rest }: PriorityClassData) { + super(rest); + this.description = description; + this.globalDefault = globalDefault; + this.preemptionPolicy = preemptionPolicy; + this.value = value; + } + + getDescription() { + return this.description || ""; + } + + getGlobalDefault() { + return (this.globalDefault || false).toString(); + } + + getPreemptionPolicy() { + return this.preemptionPolicy || "PreemptLowerPriority"; + } + + getValue() { + return this.value; + } +} + +export class PriorityClassApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: PriorityClass, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/replica-set.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/replica-set.api.injectable.ts new file mode 100644 index 0000000000..c6c75d8192 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/replica-set.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ReplicaSetApi } from "./replica-set.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const replicaSetApiInjectable = getInjectable({ + id: "replica-set-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "replicaSetApi is only available in certain environments"); + + return new ReplicaSetApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default replicaSetApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/replica-set.api.ts b/packages/core/src/common/k8s-api/endpoints/replica-set.api.ts new file mode 100644 index 0000000000..1fee553b8d --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/replica-set.api.ts @@ -0,0 +1,107 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeObjectStatus, LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; + +export class ReplicaSetApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: ReplicaSet, + }); + } + + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return `${this.getUrl(params)}/scale`; + } + + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + return (status as { replicas: number })?.replicas; + } + + scale(params: { namespace: string; name: string }, replicas: number) { + return this.request.put(this.getScaleApiUrl(params), { + data: { + metadata: params, + spec: { + replicas, + }, + }, + }); + } +} + +export interface ReplicaSetSpec { + replicas?: number; + selector: LabelSelector; + template?: PodTemplateSpec; + minReadySeconds?: number; +} + +export interface ReplicaSetStatus extends KubeObjectStatus { + replicas: number; + fullyLabeledReplicas?: number; + readyReplicas?: number; + availableReplicas?: number; + observedGeneration?: number; +} + +export class ReplicaSet extends KubeObject< + NamespaceScopedMetadata, + ReplicaSetStatus, + ReplicaSetSpec +> { + static kind = "ReplicaSet"; + static namespaced = true; + static apiBase = "/apis/apps/v1/replicasets"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); + } + + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template?.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template?.metadata?.labels); + } + + getTolerations() { + return this.spec.template?.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template?.spec?.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } + + getDesired() { + return this.spec.replicas ?? 0; + } + + getCurrent() { + return this.status?.availableReplicas ?? 0; + } + + getReady() { + return this.status?.readyReplicas ?? 0; + } + + getImages() { + const containers = this.spec.template?.spec?.containers ?? []; + + return containers.map(container => container.image); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/resource-applier.api/request-patch.injectable.ts b/packages/core/src/common/k8s-api/endpoints/resource-applier.api/request-patch.injectable.ts new file mode 100644 index 0000000000..49271fb6d2 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/resource-applier.api/request-patch.injectable.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Patch } from "rfc6902"; +import apiBaseInjectable from "../../api-base.injectable"; +import type { AsyncResult } from "../../../utils/async-result"; +import type { KubeJsonApiData } from "../../kube-json-api"; + +export type RequestKubeObjectPatch = (name: string, kind: string, ns: string | undefined, patch: Patch) => Promise>; + +const requestKubeObjectPatchInjectable = getInjectable({ + id: "request-kube-object-patch", + instantiate: (di): RequestKubeObjectPatch => { + const apiBase = di.inject(apiBaseInjectable); + + return async (name, kind, ns, patch) => { + const result = await apiBase.patch("/stack", { + data: { + name, + kind, + ns, + patch, + }, + }) as AsyncResult; + + if (!result.callWasSuccessful) { + return result; + } + + try { + const response = JSON.parse(result.response); + + return { + callWasSuccessful: true, + response, + }; + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + }; + }, +}); + +export default requestKubeObjectPatchInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/resource-applier.api/request-update.injectable.ts b/packages/core/src/common/k8s-api/endpoints/resource-applier.api/request-update.injectable.ts new file mode 100644 index 0000000000..1891a779cf --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/resource-applier.api/request-update.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import apiBaseInjectable from "../../api-base.injectable"; +import type { AsyncResult } from "../../../utils/async-result"; +import type { KubeJsonApiData } from "../../kube-json-api"; + +export type RequestKubeObjectCreation = (resourceDescriptor: string) => Promise>; + +const requestKubeObjectCreationInjectable = getInjectable({ + id: "request-kube-object-creation", + instantiate: (di): RequestKubeObjectCreation => { + const apiBase = di.inject(apiBaseInjectable); + + return async (data) => { + const result = await apiBase.post("/stack", { data }) as AsyncResult; + + if (!result.callWasSuccessful) { + return result; + } + + try { + const response = JSON.parse(result.response); + + return { + callWasSuccessful: true, + response, + }; + } catch (error) { + return { + callWasSuccessful: false, + error: String(error), + }; + } + }; + }, +}); + +export default requestKubeObjectCreationInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts new file mode 100644 index 0000000000..a82fa3abd9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ResourceQuotaApi } from "./resource-quota.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const resourceQuotaApiInjectable = getInjectable({ + id: "resource-quota-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "resourceQuotaApi is only available in certain environments"); + + return new ResourceQuotaApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default resourceQuotaApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/resource-quota.api.ts b/packages/core/src/common/k8s-api/endpoints/resource-quota.api.ts new file mode 100644 index 0000000000..9316f9d932 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/resource-quota.api.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export type IResourceQuotaValues = Partial> & { + // Compute Resource Quota + "limits.cpu"?: string; + "limits.memory"?: string; + "requests.cpu"?: string; + "requests.memory"?: string; + + // Storage Resource Quota + "requests.storage"?: string; + "persistentvolumeclaims"?: string; + + // Object Count Quota + "count/pods"?: string; + "count/persistentvolumeclaims"?: string; + "count/services"?: string; + "count/secrets"?: string; + "count/configmaps"?: string; + "count/replicationcontrollers"?: string; + "count/deployments.apps"?: string; + "count/replicasets.apps"?: string; + "count/statefulsets.apps"?: string; + "count/jobs.batch"?: string; + "count/cronjobs.batch"?: string; + "count/deployments.extensions"?: string; +}; + +export interface ResourceQuotaSpec { + hard: IResourceQuotaValues; + scopeSelector?: { + matchExpressions: { + operator: string; + scopeName: string; + values: string[]; + }[]; + }; +} + +export interface ResourceQuotaStatus { + hard: IResourceQuotaValues; + used: IResourceQuotaValues; +} + +export class ResourceQuota extends KubeObject< + NamespaceScopedMetadata, + ResourceQuotaStatus, + ResourceQuotaSpec +> { + static readonly kind = "ResourceQuota"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/resourcequotas"; + + getScopeSelector() { + return this.spec.scopeSelector?.matchExpressions ?? []; + } +} + +export class ResourceQuotaApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: ResourceQuota, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/role-binding.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/role-binding.api.injectable.ts new file mode 100644 index 0000000000..489b20401a --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/role-binding.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { RoleBindingApi } from "./role-binding.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const roleBindingApiInjectable = getInjectable({ + id: "role-binding-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "roleBindingApi is only available in certain environments"); + + return new RoleBindingApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default roleBindingApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/role-binding.api.ts b/packages/core/src/common/k8s-api/endpoints/role-binding.api.ts new file mode 100644 index 0000000000..3923efee89 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/role-binding.api.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { RoleRef } from "./types/role-ref"; +import type { Subject } from "./types/subject"; + +export interface RoleBindingData extends KubeJsonApiData, void, void> { + subjects?: Subject[]; + roleRef: RoleRef; +} + +export class RoleBinding extends KubeObject< + NamespaceScopedMetadata, + void, + void +> { + static readonly kind = "RoleBinding"; + static readonly namespaced = true; + static readonly apiBase = "/apis/rbac.authorization.k8s.io/v1/rolebindings"; + + subjects?: Subject[]; + roleRef: RoleRef; + + constructor({ subjects, roleRef, ...rest }: RoleBindingData) { + super(rest); + this.subjects = subjects; + this.roleRef = roleRef; + } + + getSubjects() { + return this.subjects || []; + } + + getSubjectNames(): string { + return this.getSubjects().map(subject => subject.name).join(", "); + } +} + +export class RoleBindingApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: RoleBinding, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/role.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/role.api.injectable.ts new file mode 100644 index 0000000000..d91d6a1fb8 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/role.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { RoleApi } from "./role.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const roleApiInjectable = getInjectable({ + id: "role-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "roleApi is only available in certain environments"); + + return new RoleApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default roleApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/role.api.ts b/packages/core/src/common/k8s-api/endpoints/role.api.ts new file mode 100644 index 0000000000..d6bd0e79d3 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/role.api.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { PolicyRule } from "./types/policy-rule"; + +export interface RoleData extends KubeJsonApiData, void, void> { + rules?: PolicyRule[]; +} + +export class Role extends KubeObject< + NamespaceScopedMetadata, + void, + void +> { + static readonly kind = "Role"; + static readonly namespaced = true; + static readonly apiBase = "/apis/rbac.authorization.k8s.io/v1/roles"; + rules?: PolicyRule[]; + + constructor({ rules, ...rest }: RoleData) { + super(rest); + this.rules = rules; + } + + getRules() { + return this.rules || []; + } +} + +export class RoleApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: Role, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/runtime-class.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/runtime-class.api.injectable.ts new file mode 100644 index 0000000000..3fbeab9a09 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/runtime-class.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { RuntimeClassApi } from "./runtime-class.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const runtimeClassApiInjectable = getInjectable({ + id: "runtime-class-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "RuntimeClassApi is only available in certain environments"); + + return new RuntimeClassApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default runtimeClassApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/runtime-class.api.ts b/packages/core/src/common/k8s-api/endpoints/runtime-class.api.ts new file mode 100644 index 0000000000..16e3cefab5 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/runtime-class.api.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { ClusterScopedMetadata, KubeObjectMetadata, KubeObjectScope, Toleration } from "../kube-object"; +import { KubeObject } from "../kube-object"; + +export interface RuntimeClassData extends KubeJsonApiData, void, void> { + handler: string; + overhead?: RuntimeClassOverhead; + scheduling?: RuntimeClassScheduling; +} + +export interface RuntimeClassOverhead { + podFixed?: string; +} + +export interface RuntimeClassScheduling { + nodeSelector?: Partial>; + tolerations?: Toleration[]; +} + +export class RuntimeClass extends KubeObject< + ClusterScopedMetadata, + void, + void +> { + static readonly kind = "RuntimeClass"; + static readonly namespaced = false; + static readonly apiBase = "/apis/node.k8s.io/v1/runtimeclasses"; + + handler: string; + overhead?: RuntimeClassOverhead; + scheduling?: RuntimeClassScheduling; + + constructor({ handler, overhead, scheduling, ...rest }: RuntimeClassData) { + super(rest); + this.handler = handler; + this.overhead = overhead; + this.scheduling = scheduling; + } + + getHandler() { + return this.handler; + } + + getPodFixed() { + return this.overhead?.podFixed ?? ""; + } + + getNodeSelectors(): string[] { + return Object.entries(this.scheduling?.nodeSelector ?? {}) + .map(values => values.join(": ")); + } + + getTolerations() { + return this.scheduling?.tolerations ?? []; + } +} + +export class RuntimeClassApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + objectConstructor: RuntimeClass, + ...opts, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/secret.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/secret.api.injectable.ts new file mode 100644 index 0000000000..c4173987da --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/secret.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { SecretApi } from "./secret.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const secretApiInjectable = getInjectable({ + id: "secret-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "secretApi is only available in certain environments"); + + return new SecretApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default secretApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/secret.api.ts b/packages/core/src/common/k8s-api/endpoints/secret.api.ts new file mode 100644 index 0000000000..a5a3b5eb1c --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/secret.api.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { KubeJsonApiData } from "../kube-json-api"; +import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export enum SecretType { + Opaque = "Opaque", + ServiceAccountToken = "kubernetes.io/service-account-token", + Dockercfg = "kubernetes.io/dockercfg", + DockerConfigJson = "kubernetes.io/dockerconfigjson", + BasicAuth = "kubernetes.io/basic-auth", + SSHAuth = "kubernetes.io/ssh-auth", + TLS = "kubernetes.io/tls", + BootstrapToken = "bootstrap.kubernetes.io/token", +} + +export const reverseSecretTypeMap = { + [SecretType.Opaque]: "Opaque", + [SecretType.ServiceAccountToken]: "ServiceAccountToken", + [SecretType.Dockercfg]: "Dockercfg", + [SecretType.DockerConfigJson]: "DockerConfigJson", + [SecretType.BasicAuth]: "BasicAuth", + [SecretType.SSHAuth]: "SSHAuth", + [SecretType.TLS]: "TLS", + [SecretType.BootstrapToken]: "BootstrapToken", +}; + +export interface SecretReference { + name: string; + namespace?: string; +} + +export interface SecretData extends KubeJsonApiData, void, void> { + type: SecretType; + data?: Partial>; +} + +export class Secret extends KubeObject< + NamespaceScopedMetadata, + void, + void +> { + static readonly kind = "Secret"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/secrets"; + + type: SecretType; + data: Partial>; + + constructor({ data = {}, type, ...rest }: SecretData) { + super(rest); + autoBind(this); + + this.data = data; + this.type = type; + } + + getKeys(): string[] { + return Object.keys(this.data); + } + + getToken() { + return this.data.token; + } +} + +export class SecretApi extends KubeApi { + constructor(deps: KubeApiDependencies, options: DerivedKubeApiOptions = {}) { + super(deps, { + ...options, + objectConstructor: Secret, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.injectable.ts new file mode 100644 index 0000000000..8034b9d47f --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { SelfSubjectRulesReviewApi } from "./self-subject-rules-reviews.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const selfSubjectRulesReviewApiInjectable = getInjectable({ + id: "self-subject-rules-review-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "selfSubjectRulesReviewApi is only available in certain environments"); + + return new SelfSubjectRulesReviewApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default selfSubjectRulesReviewApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.ts b/packages/core/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.ts new file mode 100644 index 0000000000..2c73d89e77 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/self-subject-rules-reviews.api.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export class SelfSubjectRulesReviewApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: SelfSubjectRulesReview, + }); + } + + create({ namespace = "default" }) { + return super.create({}, { + spec: { + namespace, + }, + }); + } +} + +export interface ISelfSubjectReviewRule { + verbs: string[]; + apiGroups?: string[]; + resources?: string[]; + resourceNames?: string[]; + nonResourceURLs?: string[]; +} + +export interface SelfSubjectRulesReview { + spec: { + namespace?: string; + }; + status: { + resourceRules: ISelfSubjectReviewRule[]; + nonResourceRules: ISelfSubjectReviewRule[]; + incomplete: boolean; + }; +} + +export class SelfSubjectRulesReview extends KubeObject { + static kind = "SelfSubjectRulesReview"; + static namespaced = false; + static apiBase = "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews"; + + getResourceRules() { + const rules = this.status && this.status.resourceRules || []; + + return rules.map(rule => this.normalize(rule)); + } + + getNonResourceRules() { + const rules = this.status && this.status.nonResourceRules || []; + + return rules.map(rule => this.normalize(rule)); + } + + protected normalize(rule: ISelfSubjectReviewRule): ISelfSubjectReviewRule { + const { apiGroups = [], resourceNames = [], verbs = [], nonResourceURLs = [], resources = [] } = rule; + + return { + apiGroups, + nonResourceURLs, + resourceNames, + verbs, + resources: resources.map((resource, index) => { + const apiGroup = apiGroups.length >= index + 1 ? apiGroups[index] : apiGroups.slice(-1)[0]; + const separator = apiGroup == "" ? "" : "."; + + return resource + separator + apiGroup; + }), + }; + } +} + diff --git a/packages/core/src/common/k8s-api/endpoints/service-account.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/service-account.api.injectable.ts new file mode 100644 index 0000000000..70a477ac54 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/service-account.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ServiceAccountApi } from "./service-account.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const serviceAccountApiInjectable = getInjectable({ + id: "service-account-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "serviceAccountApi is only available in certain environments"); + + return new ServiceAccountApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default serviceAccountApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/service-account.api.ts b/packages/core/src/common/k8s-api/endpoints/service-account.api.ts new file mode 100644 index 0000000000..e92ea667d9 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/service-account.api.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectMetadata, KubeObjectScope, LocalObjectReference, NamespaceScopedMetadata, ObjectReference } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; + +export interface ServiceAccountData extends KubeJsonApiData, void, void> { + automountServiceAccountToken?: boolean; + imagePullSecrets?: LocalObjectReference[]; + secrets?: ObjectReference[]; +} + +export class ServiceAccount extends KubeObject< + NamespaceScopedMetadata, + void, + void +> { + static readonly kind = "ServiceAccount"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/serviceaccounts"; + + automountServiceAccountToken?: boolean; + imagePullSecrets?: LocalObjectReference[]; + secrets?: ObjectReference[]; + + constructor({ + automountServiceAccountToken, + imagePullSecrets, + secrets, + ...rest + }: ServiceAccountData) { + super(rest); + this.automountServiceAccountToken = automountServiceAccountToken; + this.imagePullSecrets = imagePullSecrets; + this.secrets = secrets; + } + + getSecrets() { + return this.secrets || []; + } + + getImagePullSecrets() { + return this.imagePullSecrets || []; + } +} + +export class ServiceAccountApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: ServiceAccount, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/service.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/service.api.injectable.ts new file mode 100644 index 0000000000..78a0f744de --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/service.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { ServiceApi } from "./service.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const serviceApiInjectable = getInjectable({ + id: "service-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "serviceApi is only available in certain environments"); + + return new ServiceApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default serviceApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/service.api.ts b/packages/core/src/common/k8s-api/endpoints/service.api.ts new file mode 100644 index 0000000000..f574bea53d --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/service.api.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; + +export interface ServicePort { + name?: string; + protocol: string; + port: number; + targetPort: number; + nodePort?: number; +} + +export class ServicePort { + constructor(data: ServicePort) { + Object.assign(this, data); + } + + toString() { + if (this.nodePort) { + return `${this.port}:${this.nodePort}/${this.protocol}`; + } else { + return `${this.port}${this.port === this.targetPort ? "" : `:${this.targetPort}`}/${this.protocol}`; + } + } +} + +export interface ServiceSpec { + type: string; + clusterIP: string; + clusterIPs?: string[]; + externalTrafficPolicy?: string; + externalName?: string; + loadBalancerIP?: string; + loadBalancerSourceRanges?: string[]; + sessionAffinity: string; + selector: Partial>; + ports: ServicePort[]; + healthCheckNodePort?: number; + externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips + topologyKeys?: string[]; + ipFamilies?: string[]; + ipFamilyPolicy?: string; + allocateLoadBalancerNodePorts?: boolean; + loadBalancerClass?: string; + internalTrafficPolicy?: string; +} + +export interface ServiceStatus { + loadBalancer?: { + ingress?: { + ip?: string; + hostname?: string; + }[]; + }; +} + +export class Service extends KubeObject< + NamespaceScopedMetadata, + ServiceStatus, + ServiceSpec +> { + static readonly kind = "Service"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/services"; + + getClusterIp() { + return this.spec.clusterIP; + } + + getClusterIps() { + return this.spec.clusterIPs || []; + } + + getExternalIps() { + const lb = this.getLoadBalancer(); + + if (lb?.ingress) { + return lb.ingress.map(val => val.ip || val.hostname); + } + + if (Array.isArray(this.spec?.externalIPs)) { + return this.spec.externalIPs; + } + + return []; + } + + getType() { + return this.spec.type || "-"; + } + + getSelector(): string[] { + if (!this.spec.selector) return []; + + return Object.entries(this.spec.selector).map(val => val.join("=")); + } + + getPorts(): ServicePort[] { + const ports = this.spec.ports || []; + + return ports.map(p => new ServicePort(p)); + } + + getLoadBalancer() { + return this.status?.loadBalancer; + } + + isActive() { + return this.getType() !== "LoadBalancer" || this.getExternalIps().length > 0; + } + + getStatus() { + return this.isActive() ? "Active" : "Pending"; + } + + getIpFamilies() { + return this.spec.ipFamilies || []; + } + + getIpFamilyPolicy() { + return this.spec.ipFamilyPolicy || ""; + } +} + +export class ServiceApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: Service, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts new file mode 100644 index 0000000000..1dd58fde3c --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { StatefulSetApi } from "./stateful-set.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const statefulSetApiInjectable = getInjectable({ + id: "stateful-set-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "statefulSetApi is only available in certain environments"); + + return new StatefulSetApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default statefulSetApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/stateful-set.api.ts b/packages/core/src/common/k8s-api/endpoints/stateful-set.api.ts new file mode 100644 index 0000000000..e91a856262 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/stateful-set.api.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import moment from "moment"; + +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { LabelSelector, NamespaceScopedMetadata } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; +import type { PersistentVolumeClaimTemplateSpec } from "./types/persistent-volume-claim-template-spec"; + +export class StatefulSetApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { + super(deps, { + ...opts ?? {}, + objectConstructor: StatefulSet, + }); + } + + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return `${this.getUrl(params)}/scale`; + } + + getReplicas(params: { namespace: string; name: string }): Promise { + return this.request + .get(this.getScaleApiUrl(params)) + .then(({ status }: any) => status?.replicas); + } + + scale(params: { namespace: string; name: string }, replicas: number) { + return this.request.patch(this.getScaleApiUrl(params), { + data: { + spec: { + replicas, + }, + }, + }, + { + headers: { + "content-type": "application/merge-patch+json", + }, + }); + } + + restart(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + template: { + metadata: { + annotations: { "kubectl.kubernetes.io/restartedAt" : moment.utc().format() }, + }, + }, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } +} + +export interface StatefulSetSpec { + serviceName: string; + replicas: number; + selector: LabelSelector; + template: PodTemplateSpec; + volumeClaimTemplates: PersistentVolumeClaimTemplateSpec[]; +} + +export interface StatefulSetStatus { + observedGeneration: number; + replicas: number; + currentReplicas: number; + readyReplicas: number; + currentRevision: string; + updateRevision: string; + collisionCount: number; +} + +export class StatefulSet extends KubeObject< + NamespaceScopedMetadata, + StatefulSetStatus, + StatefulSetSpec +> { + static readonly kind = "StatefulSet"; + static readonly namespaced = true; + static readonly apiBase = "/apis/apps/v1/statefulsets"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); + } + + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata?.labels); + } + + getTolerations() { + return this.spec.template.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec?.affinity ?? {}; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity()).length; + } + + getReplicas() { + return this.spec.replicas || 0; + } + + getImages() { + const containers = this.spec.template?.spec?.containers ?? []; + + return containers.map(container => container.image); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/storage-class.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/storage-class.api.injectable.ts new file mode 100644 index 0000000000..e65247aa8d --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/storage-class.api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { storesAndApisCanBeCreatedInjectionToken } from "../stores-apis-can-be-created.token"; +import { StorageClassApi } from "./storage-class.api"; +import { kubeApiInjectionToken } from "../kube-api/kube-api-injection-token"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; + +const storageClassApiInjectable = getInjectable({ + id: "storage-class-api", + instantiate: (di) => { + assert(di.inject(storesAndApisCanBeCreatedInjectionToken), "storageClassApi is only available in certain environments"); + + return new StorageClassApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }); + }, + + injectionToken: kubeApiInjectionToken, +}); + +export default storageClassApiInjectable; diff --git a/packages/core/src/common/k8s-api/endpoints/storage-class.api.ts b/packages/core/src/common/k8s-api/endpoints/storage-class.api.ts new file mode 100644 index 0000000000..bf121e47db --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/storage-class.api.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { autoBind } from "../../utils"; +import type { ClusterScopedMetadata, KubeObjectMetadata, KubeObjectScope } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; + +export interface TopologySelectorLabelRequirement { + key: string; + values: string[]; +} + +export interface TopologySelectorTerm { + matchLabelExpressions?: TopologySelectorLabelRequirement[]; +} + +export interface StorageClassData extends KubeJsonApiData, void, void> { + allowVolumeExpansion?: boolean; + allowedTopologies?: TopologySelectorTerm[]; + mountOptions?: string[]; + parameters?: Partial>; + provisioner: string; + reclaimPolicy?: string; + volumeBindingMode?: string; +} + +export class StorageClass extends KubeObject< + ClusterScopedMetadata, + void, + void +> { + static readonly kind = "StorageClass"; + static readonly namespaced = false; + static readonly apiBase = "/apis/storage.k8s.io/v1/storageclasses"; + + allowVolumeExpansion?: boolean; + allowedTopologies: TopologySelectorTerm[]; + mountOptions: string[]; + parameters: Partial>; + provisioner: string; + reclaimPolicy: string; + volumeBindingMode?: string; + + constructor({ + allowVolumeExpansion, + allowedTopologies = [], + mountOptions = [], + parameters = {}, + provisioner, + reclaimPolicy = "Delete", + volumeBindingMode, + ...rest + }: StorageClassData) { + super(rest); + autoBind(this); + this.allowVolumeExpansion = allowVolumeExpansion; + this.allowedTopologies = allowedTopologies; + this.mountOptions = mountOptions; + this.parameters = parameters; + this.provisioner = provisioner; + this.reclaimPolicy = reclaimPolicy; + this.volumeBindingMode = volumeBindingMode; + } + + isDefault() { + const annotations = this.metadata.annotations || {}; + + return ( + annotations["storageclass.kubernetes.io/is-default-class"] === "true" || + annotations["storageclass.beta.kubernetes.io/is-default-class"] === "true" + ); + } + + getVolumeBindingMode() { + return this.volumeBindingMode || "-"; + } + + getReclaimPolicy() { + return this.reclaimPolicy || "-"; + } +} + +export class StorageClassApi extends KubeApi { + constructor(deps: KubeApiDependencies, opts: DerivedKubeApiOptions = {}) { + super(deps, { + ...opts, + objectConstructor: StorageClass, + }); + } +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/aggregation-rule.ts b/packages/core/src/common/k8s-api/endpoints/types/aggregation-rule.ts new file mode 100644 index 0000000000..57ebe32264 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/aggregation-rule.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LabelSelector } from "../../kube-object"; + +export interface AggregationRule { + clusterRoleSelectors?: LabelSelector; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/capabilities.ts b/packages/core/src/common/k8s-api/endpoints/types/capabilities.ts new file mode 100644 index 0000000000..baea13c688 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/capabilities.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Adds and removes POSIX capabilities from running containers. + */ +export interface Capabilities { + /** + * Added capabilities + */ + add?: string[]; + + /** + * Removed capabilities + */ + drop?: string[]; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/container-port.ts b/packages/core/src/common/k8s-api/endpoints/types/container-port.ts new file mode 100644 index 0000000000..9ccb635e98 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/container-port.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface ContainerPort { + containerPort: number; + hostIP?: string; + hostPort?: number; + name?: string; + protocol?: "UDP" | "TCP" | "SCTP"; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/container.ts b/packages/core/src/common/k8s-api/endpoints/types/container.ts new file mode 100644 index 0000000000..bccf45eb64 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/container.ts @@ -0,0 +1,176 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Lifecycle } from "./lifecycle"; +import type { ResourceRequirements } from "./resource-requirements"; +import type { SecurityContext } from "./security-context"; +import type { Probe } from "./probe"; +import type { VolumeDevice } from "./volume-device"; +import type { VolumeMount } from "./volume-mount"; +import type { ContainerPort } from "./container-port"; +import type { EnvFromSource } from "./env-from-source"; +import type { EnvVar } from "./env-var"; + +/** + * A single application container that you want to run within a pod. + */ +export interface Container { + /** + * Arguments to the entrypoint. The docker image's CMD is used if this is not provided. Variable + * references `$(VAR_NAME)` are expanded using the container's environment. + * + * If a variable cannot be resolved, the reference in the input string will be unchanged. + * Double `$$` are reduced to a single `$`, which allows for escaping the `$(VAR_NAME)` syntax: + * i.e. `"$$(VAR_NAME)"` will produce the string literal `"$(VAR_NAME)`". + * + * Escaped references will never be expanded, regardless of whether the variable exists or not. + * Cannot be updated. + * + * More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + */ + args?: string[]; + + /** + * Entrypoint array. Not executed within a shell. The docker image's ENTRYPOINT is used if this + * is not provided. Variable references `$(VAR_NAME)` are expanded using the container's + * environment. + * + * If a variable cannot be resolved, the reference in the input string will be unchanged. + * Double `$$` are reduced to a single `$`, which allows for escaping the `$(VAR_NAME)` syntax: + * i.e. `"$$(VAR_NAME)"` will produce the string literal `"$(VAR_NAME)`". + * + * Escaped references will never be expanded, regardless of whether the variable exists or not. + * Cannot be updated. + * + * More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + */ + command?: string[]; + + /** + * List of environment variables to set in the container. Cannot be updated. + */ + env?: EnvVar[]; + + /** + * List of sources to populate environment variables in the container. The keys defined within a + * source must be a C_IDENTIFIER. All invalid keys will be reported as an event when the + * container is starting. + * + * When a key exists in multiple sources, the value associated with the last source will take + * precedence. Values defined by an Env with a duplicate key will take precedence. Cannot be + * updated. + */ + envFrom?: EnvFromSource[]; + + /** + * Docker image name. + * + * More info: https://kubernetes.io/docs/concepts/containers/images + */ + image?: string; + + /** + * Image pull policy. Defaults to `"Always"` if :latest tag is specified, or `"IfNotPresent"` + * otherwise. Cannot be updated. + * + * More info: https://kubernetes.io/docs/concepts/containers/images#updating-images + */ + imagePullPolicy?: "Always" | "Never" | "IfNotPresent"; + + lifecycle?: Lifecycle; + livenessProbe?: Probe; + + /** + * Name of the container specified as a DNS_LABEL. Each container in a pod must have a unique + * name. Cannot be updated. + */ + name: string; + + /** + * List of ports to expose from the container. Exposing a port here gives the system additional + * information about the network connections a container uses, but is primarily informational. + * Not specifying a port here DOES NOT prevent that port from being exposed. Any port which is + * listening on the default `"0.0.0.0"` address inside a container will be accessible from the + * network. Cannot be updated. + */ + ports?: ContainerPort[]; + + readinessProbe?: Probe; + resources?: ResourceRequirements; + securityContext?: SecurityContext; + startupProbe?: Probe; + + /** + * Whether this container should allocate a buffer for stdin in the container runtime. If this is + * not set, reads from stdin in the container will always result in EOF. + * + * @default false + */ + stdin?: boolean; + + /** + * Whether the container runtime should close the stdin channel after it has been opened by a + * single attach. When stdin is true the stdin stream will remain open across multiple attach + * sessions. + * + * If stdinOnce is set to true, stdin is opened on container start, is empty until the first + * client attaches to stdin, and then remains open and accepts data until the client disconnects, + * at which time stdin is closed and remains closed until the container is restarted. + * + * If this flag is false, a container processes that reads from stdin will never receive an EOF. + * + * @default false + */ + stdinOnce?: boolean; + + /** + * Path at which the file to which the container's termination message will be written + * is mounted into the container's filesystem. Message written is intended to be brief final + * status, such as an assertion failure message. + * + * Will be truncated by the node if greater than 4096 bytes. + * The total message length across all containers will be limited to 12kb. Cannot be updated. + * + * @default "/dev/termination-log" + */ + terminationMessagePath?: string; + + /** + * Indicate how the termination message should be populated. + * + * - `File`: will use the contents of {@link terminationMessagePath} to populate the container + * status message on both success and failure. + * + * - `FallbackToLogsOnError`: will use the last chunk of container log output if the + * termination message file is empty and the container exited with an error. + * + * The log output is limited to 2048 bytes or 80 lines, whichever is smaller. Cannot be updated. + * + * @default "File" + */ + terminationMessagePolicy?: "File" | "FallbackToLogsOnError"; + + /** + * Whether this container should allocate a TTY for itself, also requires 'stdin' to be true. + * + * @default false + */ + tty?: boolean; + + /** + * volumeDevices is the list of block devices to be used by the container. + */ + volumeDevices?: VolumeDevice[]; + + /** + * Pod volumes to mount into the container's filesystem. Cannot be updated. + */ + volumeMounts?: VolumeMount[]; + + /** + * Container's working directory. If not specified, the container runtime's default will be used, + * which might be configured in the container image. Cannot be updated. + */ + workingDir?: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/env-from-source.ts b/packages/core/src/common/k8s-api/endpoints/types/env-from-source.ts new file mode 100644 index 0000000000..fa87b2fac8 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/env-from-source.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { EnvSource } from "./env-source"; + +export interface EnvFromSource { + configMapRef?: EnvSource; + /** + * An identifier to prepend to each key in the ConfigMap. Must be a C_IDENTIFIER. + */ + prefix?: string; + secretRef?: EnvSource; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/env-source.ts b/packages/core/src/common/k8s-api/endpoints/types/env-source.ts new file mode 100644 index 0000000000..2a16ee2ada --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/env-source.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LocalObjectReference } from "../../kube-object"; + +export interface EnvSource extends LocalObjectReference { + /** + * Whether the object must be defined + */ + optional?: boolean; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/env-var-key-selector.ts b/packages/core/src/common/k8s-api/endpoints/types/env-var-key-selector.ts new file mode 100644 index 0000000000..3aca6d79bd --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/env-var-key-selector.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface EnvVarKeySelector { + key: string; + name?: string; + optional?: boolean; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/env-var-source.ts b/packages/core/src/common/k8s-api/endpoints/types/env-var-source.ts new file mode 100644 index 0000000000..c47c839d18 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/env-var-source.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { EnvVarKeySelector } from "./env-var-key-selector"; +import type { ObjectFieldSelector } from "./object-field-selector"; +import type { ResourceFieldSelector } from "./resource-field-selector"; + +export interface EnvVarSource { + configMapKeyRef?: EnvVarKeySelector; + fieldRef?: ObjectFieldSelector; + resourceFieldRef?: ResourceFieldSelector; + secretKeyRef?: EnvVarKeySelector; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/env-var.ts b/packages/core/src/common/k8s-api/endpoints/types/env-var.ts new file mode 100644 index 0000000000..4a60b69129 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/env-var.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { EnvVarSource } from "./env-var-source"; + +export interface EnvVar { + name: string; + value?: string; + valueFrom?: EnvVarSource; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/exec-action.ts b/packages/core/src/common/k8s-api/endpoints/types/exec-action.ts new file mode 100644 index 0000000000..abfd1947cb --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/exec-action.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * ExecAction describes a "run in container" action. + */ +export interface ExecAction { + /** + * Command is the command line to execute inside the container, the working directory for the + * command is root ('\\') in the container's filesystem. The command is simply exec'd, it is not + * run inside a shell, so traditional shell instructions ('|', etc) won't work. To use a shell, + * you need to explicitly call out to that shell. + * + * Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + */ + command?: string[]; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/external-documentation.ts b/packages/core/src/common/k8s-api/endpoints/types/external-documentation.ts new file mode 100644 index 0000000000..b433785323 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/external-documentation.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface ExternalDocumentation { + description?: string; + url?: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/handler.ts b/packages/core/src/common/k8s-api/endpoints/types/handler.ts new file mode 100644 index 0000000000..d30e6cd181 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/handler.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ExecAction } from "./exec-action"; +import type { HttpGetAction } from "./http-get-action"; +import type { TcpSocketAction } from "./tcp-socket-action"; + +/** + * Handler defines a specific action that should be taken. + */ +export interface Handler { + exec?: ExecAction; + httpGet?: HttpGetAction; + tcpSocket?: TcpSocketAction; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/http-get-action.ts b/packages/core/src/common/k8s-api/endpoints/types/http-get-action.ts new file mode 100644 index 0000000000..1077ea13e1 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/http-get-action.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { HttpHeader } from "./http-header"; + +/** + * An action based on HTTP Get requests. + */ +export interface HttpGetAction { + /** + * Host name to connect to, defaults to the pod IP. You probably want to set \"Host\" in httpHeaders instead. + */ + host?: string; + + /** + * Custom headers to set in the request. HTTP allows repeated headers. + */ + httpHeaders?: HttpHeader[]; + + /** + * Path to access on the HTTP server. + */ + path?: string; + + /** + * The PORT to request from. + */ + port: string | number; + + /** + * Scheme to use for connecting to the host. + * + * @default "HTTP" + */ + scheme?: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/http-header.ts b/packages/core/src/common/k8s-api/endpoints/types/http-header.ts new file mode 100644 index 0000000000..d70b42afdb --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/http-header.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * A custom header to be used in HTTP probes and get actions + */ +export interface HttpHeader { + /** + * Field name + */ + name: string; + + /** + * The value of the field + */ + value: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/index.ts b/packages/core/src/common/k8s-api/endpoints/types/index.ts new file mode 100644 index 0000000000..6fb52e7403 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/index.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./aggregation-rule"; +export * from "./capabilities"; +export * from "./container"; +export * from "./container-port"; +export * from "./env-from-source"; +export * from "./env-source"; +export * from "./env-var-key-selector"; +export * from "./env-var-source"; +export * from "./env-var"; +export * from "./exec-action"; +export * from "./handler"; +export * from "./http-get-action"; +export * from "./http-header"; +export * from "./job-template-spec"; +export * from "./lifecycle"; +export * from "./object-field-selector"; +export * from "./persistent-volume-claim-template-spec"; +export * from "./pod-security-context"; +export * from "./pod-template-spec"; +export * from "./policy-rule"; +export * from "./probe"; +export * from "./resource-field-selector"; +export * from "./resource-requirements"; +export * from "./role-ref"; +export * from "./se-linux-options"; +export * from "./seccomp-profile"; +export * from "./subject"; +export * from "./tcp-socket-action"; +export * from "./volume-device"; +export * from "./volume-mount"; +export * from "./windows-security-context-options"; diff --git a/packages/core/src/common/k8s-api/endpoints/types/job-template-spec.ts b/packages/core/src/common/k8s-api/endpoints/types/job-template-spec.ts new file mode 100644 index 0000000000..b3ae5cceca --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/job-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, KubeTemplateObjectMetadata } from "../../kube-object"; +import type { JobSpec } from "../job.api"; + +export interface JobTemplateSpec { + metadata?: KubeTemplateObjectMetadata; + spec?: JobSpec; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/json-schema-props.ts b/packages/core/src/common/k8s-api/endpoints/types/json-schema-props.ts new file mode 100644 index 0000000000..1c0f18a7d2 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/json-schema-props.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { JsonValue } from "type-fest"; +import type { ExternalDocumentation } from "./external-documentation"; + +export interface JSONSchemaProps { + $ref?: string; + $schema?: string; + additionalItems?: JSONSchemaProps | boolean; + additionalProperties?: JSONSchemaProps | boolean; + allOf?: JSONSchemaProps[]; + anyOf?: JSONSchemaProps[]; + + /** + * default is a default value for undefined object fields. + * Defaulting is a beta feature under the CustomResourceDefaulting feature gate. + * Defaulting requires spec.preserveUnknownFields to be false. + */ + _default?: object; + + definitions?: Partial>; + dependencies?: Partial>; + description?: string; + _enum?: object[]; + example?: JsonValue; + + exclusiveMaximum?: boolean; + exclusiveMinimum?: boolean; + externalDocs?: ExternalDocumentation; + + /** + * format is an OpenAPI v3 format string. + * Unknown formats are ignored. + * + * The following formats are validated: + * - bsonobjectid: a bson object ID, i.e. a 24 characters hex string + * - uri: an URI as parsed by Golang net/url.ParseRequestURI + * - email: an email address as parsed by Golang net/mail.ParseAddress + * - hostname: a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034]. + * - ipv4: an IPv4 IP as parsed by Golang net.ParseIP + * - ipv6: an IPv6 IP as parsed by Golang net.ParseIP + * - cidr: a CIDR as parsed by Golang net.ParseCIDR + * - mac: a MAC address as parsed by Golang net.ParseMAC + * - uuid: an UUID that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + * - uuid3: an UUID3 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?3[0-9a-f]{3}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + * - uuid4: an UUID4 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + * - uuid5: an UUID5 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?5[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + * - isbn: an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041" + * - isbn10: an ISBN10 number string like "0321751043" + * - isbn13: an ISBN13 number string like "978-0321751041" + * - creditcard: a credit card number defined by the regex ^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$ with any non digit characters mixed in + * - ssn: a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$ + * - hexcolor: an hexadecimal color code like "#FFFFFF: following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ + * - rgbcolor: an RGB color code like rgb like "rgb(255,255,2559" + * - byte: base64 encoded binary data + * - password: any kind of string + * - date: a date string like "2006-01-02" as defined by full-date in RFC3339 + * - duration: a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format + * - datetime: a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339. + */ + format?: string; + + id?: string; + items?: JSONSchemaProps | JSONSchemaProps[]; + maxItems?: number; + maxLength?: number; + maxProperties?: number; + maximum?: number; + minItems?: number; + minLength?: number; + minProperties?: number; + minimum?: number; + multipleOf?: number; + not?: JSONSchemaProps; + nullable?: boolean; + oneOf?: JSONSchemaProps[]; + pattern?: string; + patternProperties?: Partial>; + properties?: Partial>; + required?: Array; + title?: string; + type?: string; + uniqueItems?: boolean; + x_kubernetes_embedded_resource?: boolean; + x_kubernetes_int_or_string?: boolean; + x_kubernetes_list_map_keys?: string[]; + x_kubernetes_list_type?: string; + x_kubernetes_map_type?: string; + x_kubernetes_preserve_unknown_fields?: boolean; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/lifecycle.ts b/packages/core/src/common/k8s-api/endpoints/types/lifecycle.ts new file mode 100644 index 0000000000..2e459958c4 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/lifecycle.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Handler } from "./handler"; + +/** + * Lifecycle describes actions that the management system should take in response to container + * lifecycle events. For the PostStart and PreStop lifecycle handlers, management of the container + * blocks until the action is complete, unless the container process fails, in which case the + * handler is aborted. + */ +export interface Lifecycle { + postStart?: Handler; + preStop?: Handler; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/object-field-selector.ts b/packages/core/src/common/k8s-api/endpoints/types/object-field-selector.ts new file mode 100644 index 0000000000..8593456ff1 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/object-field-selector.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface ObjectFieldSelector { + apiVersion?: string; + fieldPath: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts b/packages/core/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts new file mode 100644 index 0000000000..5461c81807 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, KubeTemplateObjectMetadata } from "../../kube-object"; +import type { PersistentVolumeSpec } from "../persistent-volume.api"; + +export interface PersistentVolumeClaimTemplateSpec { + metadata?: KubeTemplateObjectMetadata; + spec?: PersistentVolumeSpec; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/pod-security-context.ts b/packages/core/src/common/k8s-api/endpoints/types/pod-security-context.ts new file mode 100644 index 0000000000..983ae86262 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/pod-security-context.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { SeLinuxOptions } from "./se-linux-options"; +import type { SeccompProfile } from "./seccomp-profile"; +import type { WindowsSecurityContextOptions } from "./windows-security-context-options"; +import type { Sysctl } from "../pod.api"; + + +export interface PodSecurityContext { + fsGroup?: number; + fsGroupChangePolicy?: string; + runAsGroup?: number; + runAsNonRoot?: boolean; + runAsUser?: number; + seLinuxOptions?: SeLinuxOptions; + seccompProfile?: SeccompProfile; + supplementalGroups?: number[]; + sysctls?: Sysctl; + windowsOptions?: WindowsSecurityContextOptions; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/pod-template-spec.ts b/packages/core/src/common/k8s-api/endpoints/types/pod-template-spec.ts new file mode 100644 index 0000000000..ba0df4aec7 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/pod-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObjectScope, KubeTemplateObjectMetadata } from "../../kube-object"; +import type { PodSpec } from "../pod.api"; + +export interface PodTemplateSpec { + metadata?: KubeTemplateObjectMetadata; + spec?: PodSpec; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/policy-rule.ts b/packages/core/src/common/k8s-api/endpoints/types/policy-rule.ts new file mode 100644 index 0000000000..b84e556223 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/policy-rule.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface PolicyRule { + verbs: string[]; + apiGroups?: string[]; + resources?: string[]; + resourceNames?: string[]; + nonResourceURLs?: string[]; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/preemption-policy.ts b/packages/core/src/common/k8s-api/endpoints/types/preemption-policy.ts new file mode 100644 index 0000000000..32d37b93a3 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/preemption-policy.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type PreemptionPolicy = "Never" | "PreemptLowerPriority"; diff --git a/packages/core/src/common/k8s-api/endpoints/types/probe.ts b/packages/core/src/common/k8s-api/endpoints/types/probe.ts new file mode 100644 index 0000000000..4251be5e2c --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/probe.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ExecAction } from "./exec-action"; +import type { HttpGetAction } from "./http-get-action"; +import type { TcpSocketAction } from "./tcp-socket-action"; + +/** + * Describes a health check to be performed against a container to determine whether it is alive or ready to receive traffic. + */ +export interface Probe { + exec?: ExecAction; + + /** + * Minimum consecutive failures for the probe to be considered failed after having succeeded. + * + * @default 3 + * @minimum 1 + */ + failureThreshold?: number; + + httpGet?: HttpGetAction; + + /** + * Duration after the container has started before liveness probes are initiated. + * + * More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + */ + initialDelaySeconds?: number; + + /** + * How often to perform the probe. + * + * @default 10 + * @minimum 1 + */ + periodSeconds?: number; + + /** + * Minimum consecutive successes for the probe to be considered successful after having failed. + * + * Must be 1 for liveness and startup. + * + * @default 1 + * @minimum 1 + */ + successThreshold?: number; + + tcpSocket?: TcpSocketAction; + + /** + * Duration the pod needs to terminate gracefully upon probe failure. + * + * The grace period is the duration in seconds after the processes running in the pod are sent a + * termination signal and the time when the processes are forcibly halted with a kill signal. + * + * Set this value longer than the expected cleanup time for your process. + * + * If this value is not set, the pod's terminationGracePeriodSeconds will be used. Otherwise, + * this value overrides the value provided by the pod spec. Value must be non-negative integer. + * The value zero indicates stop immediately via the kill signal (no opportunity to shut down). + * + * This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + * + * @minimum 1 + */ + terminationGracePeriodSeconds?: number; + + /** + * Duration after which the probe times out. + * + * More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + * + * @default 1 + * @minimum 1 + */ + timeoutSeconds?: number; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/resource-field-selector.ts b/packages/core/src/common/k8s-api/endpoints/types/resource-field-selector.ts new file mode 100644 index 0000000000..9727506183 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/resource-field-selector.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface ResourceFieldSelector { + containerName?: string; + divisor?: string; + resource: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/resource-requirements.ts b/packages/core/src/common/k8s-api/endpoints/types/resource-requirements.ts new file mode 100644 index 0000000000..eb3b6caffa --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/resource-requirements.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * ResourceRequirements describes the compute resource requirements. + */ +export interface ResourceRequirements { + /** + * Limits describes the maximum amount of compute resources allowed. + * + * More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + */ + limits?: Partial>; + + /** + * Requests describes the minimum amount of compute resources required. If Requests is omitted + * for a container, it defaults to Limits if that is explicitly specified, otherwise to an + * implementation-defined value. + * + * More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + */ + requests?: Partial>; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/role-ref.ts b/packages/core/src/common/k8s-api/endpoints/types/role-ref.ts new file mode 100644 index 0000000000..60e9bdda77 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/role-ref.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface RoleRef { + apiGroup: string; + kind: string; + name: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/se-linux-options.ts b/packages/core/src/common/k8s-api/endpoints/types/se-linux-options.ts new file mode 100644 index 0000000000..9e3c629192 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/se-linux-options.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * SELinuxOptions are the labels to be applied to the container + */ +export interface SeLinuxOptions { + /** + * The SELinux `level` label that applies to the container. + */ + level?: string; + + /** + * The SELinux `role` label that applies to the container. + */ + role?: string; + + /** + * The SELinux `type` label that applies to the container. + */ + type?: string; + + /** + * The SELinux `user` label that applies to the container. + */ + user?: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/seccomp-profile.ts b/packages/core/src/common/k8s-api/endpoints/types/seccomp-profile.ts new file mode 100644 index 0000000000..20eddbf94d --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/seccomp-profile.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Defines a pod's or a container's seccomp profile settings. Only one profile source may be set. + */ +export interface SeccompProfile { + /** + * Indicates a profile defined in a file on the node should be used. The profile must be + * preconfigured on the node to work. Must be a descending path, relative to the kubelet's + * configured seccomp profile location. Must only be set if type is "Localhost". + */ + localhostProfile?: string; + + /** + * Indicates which kind of seccomp profile will be applied. + * + * Options: + * + * | Value | Description | + * |--|--| + * | `Localhost` | A profile defined in a file on the node should be used. | + * | `RuntimeDefault` | The container runtime default profile should be used. | + * | `Unconfined` | No profile should be applied. | + */ + type: "Localhost" | "RuntimeDefault" | "Unconfined"; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/security-context.ts b/packages/core/src/common/k8s-api/endpoints/types/security-context.ts new file mode 100644 index 0000000000..ce5cf60e40 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/security-context.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Capabilities } from "./capabilities"; +import type { SeLinuxOptions } from "./se-linux-options"; +import type { SeccompProfile } from "./seccomp-profile"; +import type { WindowsSecurityContextOptions } from "./windows-security-context-options"; + +/** + * SecurityContext holds security configuration that will be applied to a container. Some fields are present in both SecurityContext and PodSecurityContext. When both are set, the values in SecurityContext take precedence. + */ +export interface SecurityContext { + /** + * AllowPrivilegeEscalation controls whether a process can gain more privileges than its parent process. This bool directly controls if the no_new_privs flag will be set on the container process. AllowPrivilegeEscalation is true always when the container is: 1) run as Privileged 2) has CAP_SYS_ADMIN + */ + allowPrivilegeEscalation?: boolean; + + capabilities?: Capabilities; + + /** + * Run container in privileged mode. Processes in privileged containers are essentially equivalent to root on the host. Defaults to false. + */ + privileged?: boolean; + + /** + * procMount denotes the type of proc mount to use for the containers. The default is DefaultProcMount which uses the container runtime defaults for readonly paths and masked paths. This requires the ProcMountType feature flag to be enabled. + */ + procMount?: string; + + /** + * Whether this container has a read-only root filesystem. Default is false. + */ + readOnlyRootFilesystem?: boolean; + + /** + * The GID to run the entrypoint of the container process. Uses runtime default if unset. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + */ + runAsGroup?: number; + + /** + * Indicates that the container must run as a non-root user. If true, the Kubelet will validate the image at runtime to ensure that it does not run as UID 0 (root) and fail to start the container if it does. If unset or false, no such validation will be performed. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + */ + runAsNonRoot?: boolean; + + /** + * The UID to run the entrypoint of the container process. Defaults to user specified in image metadata if unspecified. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + */ + runAsUser?: number; + + seLinuxOptions?: SeLinuxOptions; + seccompProfile?: SeccompProfile; + windowsOptions?: WindowsSecurityContextOptions; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/subject.ts b/packages/core/src/common/k8s-api/endpoints/types/subject.ts new file mode 100644 index 0000000000..ba4e37f1a4 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/subject.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type SubjectKind = "Group" | "ServiceAccount" | "User"; + +export interface Subject { + apiGroup?: string; + kind: SubjectKind; + name: string; + namespace?: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/tcp-socket-action.ts b/packages/core/src/common/k8s-api/endpoints/types/tcp-socket-action.ts new file mode 100644 index 0000000000..02f1805823 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/tcp-socket-action.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * An action based on opening a socket + */ +export interface TcpSocketAction { + /** + * Host name to connect to, defaults to the pod IP. + */ + host?: string; + + /** + * Port to connect to + */ + port: number | string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/volume-device.ts b/packages/core/src/common/k8s-api/endpoints/types/volume-device.ts new file mode 100644 index 0000000000..1dacaafe21 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/volume-device.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * A mapping of a raw block device within a container. + */ +export interface VolumeDevice { + /** + * The path inside of the container that the device will be mapped to. + */ + devicePath: string; + + /** + * Must match the name of a persistentVolumeClaim in the pod + */ + name: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/volume-mount.ts b/packages/core/src/common/k8s-api/endpoints/types/volume-mount.ts new file mode 100644 index 0000000000..0066d8896d --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/volume-mount.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface VolumeMount { + name: string; + readOnly?: boolean; + mountPath: string; + mountPropagation?: string; + subPath?: string; + subPathExpr?: string; +} diff --git a/packages/core/src/common/k8s-api/endpoints/types/windows-security-context-options.ts b/packages/core/src/common/k8s-api/endpoints/types/windows-security-context-options.ts new file mode 100644 index 0000000000..07daf48731 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/windows-security-context-options.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Windows-specific options and credentials. + */ +export interface WindowsSecurityContextOptions { + /** + * The location of the GMSA admission webhook (https://github.com/kubernetes-sigs/windows-gmsa) + * inlines the contents of the GMSA credential spec named by the GMSACredentialSpecName field. + */ + gmsaCredentialSpec?: string; + + /** + * The name of the GMSA credential spec to use. + */ + gmsaCredentialSpecName?: string; + + /** + * Determines if a container should be run as a 'Host Process' container. + * + * This field is alpha-level and will only be honored by components that enable the + * WindowsHostProcessContainers feature flag. + * + * Setting this field without the feature flag will result in errors when validating the Pod. + * + * All of a Pod's containers must have the same effective HostProcess value (it is not allowed to + * have a mix of HostProcess containers and non-HostProcess containers). + * + * In addition, if HostProcess is true then HostNetwork must also be set to true. + */ + hostProcess?: boolean; + + /** + * The UserName in Windows to run the entrypoint of the container process. Defaults to the user specified in image metadata if unspecified. May also be set in PodSecurityContext. If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + */ + runAsUserName?: string; +} diff --git a/packages/core/src/common/k8s-api/json-api.ts b/packages/core/src/common/k8s-api/json-api.ts new file mode 100644 index 0000000000..988378641c --- /dev/null +++ b/packages/core/src/common/k8s-api/json-api.ts @@ -0,0 +1,258 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Base http-service / json-api class + +import { Agent as HttpAgent } from "http"; +import { Agent as HttpsAgent } from "https"; +import { merge } from "lodash"; +import type { Response, RequestInit } from "node-fetch"; +import { stringify } from "querystring"; +import type { Patch } from "rfc6902"; +import type { PartialDeep, ValueOf } from "type-fest"; +import { EventEmitter } from "../../common/event-emitter"; +import type { Logger } from "../../common/logger"; +import type { Fetch } from "../fetch/fetch.injectable"; +import type { Defaulted } from "../utils"; +import { json } from "../utils"; + +export interface JsonApiData {} + +export interface JsonApiError { + code?: number; + message?: string; + errors?: { id: string; title: string; status?: number }[]; +} + +export interface JsonApiParams { + data?: PartialDeep; // request body +} + +export interface JsonApiLog { + method: string; + reqUrl: string; + reqInit: RequestInit; + data?: any; + error?: any; +} + +export type GetRequestOptions = () => Promise; + +export interface JsonApiConfig { + apiBase: string; + serverAddress: string; + debug?: boolean; + getRequestOptions?: GetRequestOptions; +} + +const httpAgent = new HttpAgent({ keepAlive: true }); +const httpsAgent = new HttpsAgent({ keepAlive: true }); + +export type QueryParam = string | number | boolean | null | undefined | readonly string[] | readonly number[] | readonly boolean[]; +export type QueryParams = Partial>; + +export type ParamsAndQuery = ( + ValueOf extends QueryParam + ? Params & { query?: Query } + : Params & { query?: undefined } +); + +export interface JsonApiDependencies { + fetch: Fetch; + readonly logger: Logger; +} + +export class JsonApi = JsonApiParams> { + static readonly reqInitDefault = { + headers: { + "content-type": "application/json", + }, + }; + protected readonly reqInit: Defaulted; + + static readonly configDefault: Partial = { + debug: false, + }; + + constructor(protected readonly dependencies: JsonApiDependencies, public readonly config: JsonApiConfig, reqInit?: RequestInit) { + this.config = Object.assign({}, JsonApi.configDefault, config); + this.reqInit = merge({}, JsonApi.reqInitDefault, reqInit); + this.parseResponse = this.parseResponse.bind(this); + this.getRequestOptions = config.getRequestOptions ?? (() => Promise.resolve({})); + } + + public readonly onData = new EventEmitter<[Data, Response]>(); + public readonly onError = new EventEmitter<[JsonApiErrorParsed, Response]>(); + private readonly getRequestOptions: GetRequestOptions; + + async getResponse( + path: string, + params?: ParamsAndQuery, + init: RequestInit = {}, + ): Promise { + let reqUrl = `${this.config.serverAddress}${this.config.apiBase}${path}`; + const reqInit = merge( + { + method: "get", + agent: reqUrl.startsWith("https:") ? httpsAgent : httpAgent, + }, + this.reqInit, + await this.getRequestOptions(), + init, + ); + const { query } = params ?? {}; + + if (query && Object.keys(query).length > 0) { + const queryString = stringify(query as unknown as QueryParams); + + reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; + } + + return this.dependencies.fetch(reqUrl, reqInit); + } + + get( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "get" }); + } + + post( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "post" }); + } + + put( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "put" }); + } + + patch( + path: string, + params?: (ParamsAndQuery, Query> & { data?: Patch | PartialDeep }), + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "patch" }); + } + + del( + path: string, + params?: ParamsAndQuery, + reqInit: RequestInit = {}, + ) { + return this.request(path, params, { ...reqInit, method: "delete" }); + } + + protected async request( + path: string, + params: (ParamsAndQuery, Query> & { data?: unknown }) | undefined, + init: Defaulted, + ) { + let reqUrl = `${this.config.serverAddress}${this.config.apiBase}${path}`; + const reqInit = merge( + {}, + this.reqInit, + await this.getRequestOptions(), + init, + ); + const { data, query } = params || {}; + + if (data && !reqInit.body) { + reqInit.body = JSON.stringify(data); + } + + if (query && Object.keys(query).length > 0) { + const queryString = stringify(query as unknown as QueryParams); + + reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; + } + const infoLog: JsonApiLog = { + method: reqInit.method.toUpperCase(), + reqUrl, + reqInit, + }; + + const res = await this.dependencies.fetch(reqUrl, reqInit); + + return this.parseResponse(res, infoLog); + } + + protected async parseResponse(res: Response, log: JsonApiLog): Promise { + const { status } = res; + + const text = await res.text(); + let data: any; + + try { + data = text ? json.parse(text) : ""; // DELETE-requests might not have response-body + } catch (e) { + data = text; + } + + if (status >= 200 && status < 300) { + this.onData.emit(data, res); + this.writeLog({ ...log, data }); + + return data; + } + + if (log.method === "GET" && res.status === 403) { + this.writeLog({ ...log, error: data }); + throw data; + } + + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + + this.onError.emit(error, res); + this.writeLog({ ...log, error }); + + throw error; + } + + protected parseError(error: JsonApiError | string, res: Response): string[] { + if (typeof error === "string") { + return [error]; + } + + if (Array.isArray(error.errors)) { + return error.errors.map(error => error.title); + } + + if (error.message) { + return [error.message]; + } + + return [res.statusText || "Error!"]; + } + + protected writeLog(log: JsonApiLog) { + const { method, reqUrl, ...params } = log; + + this.dependencies.logger.debug(`[JSON-API] request ${method} ${reqUrl}`, params); + } +} + +export class JsonApiErrorParsed { + isUsedForNotification = false; + + constructor(private error: JsonApiError | DOMException, private messages: string[]) { + } + + get isAborted() { + return this.error.code === DOMException.ABORT_ERR; + } + + toString() { + return this.messages.join("\n"); + } +} diff --git a/packages/core/src/common/k8s-api/kube-api-parse.ts b/packages/core/src/common/k8s-api/kube-api-parse.ts new file mode 100644 index 0000000000..cb5315b50c --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-api-parse.ts @@ -0,0 +1,126 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Parse kube-api path and get api-version, group, etc. + +import { splitArray } from "../utils"; + +export interface IKubeApiLinkRef { + apiPrefix?: string; + apiVersion: string; + resource: string; + name?: string; + namespace?: string; +} + +export interface IKubeApiParsed extends IKubeApiLinkRef { + apiBase: string; + apiPrefix: string; + apiGroup: string; + apiVersionWithGroup: string; +} + +export function parseKubeApi(path: string): IKubeApiParsed { + const apiPath = new URL(path, "https://localhost").pathname; + const [, prefix, ...parts] = apiPath.split("/"); + const apiPrefix = `/${prefix}`; + const [left, right, namespaced] = splitArray(parts, "namespaces"); + let apiGroup!: string; + let apiVersion!: string; + let namespace!: string; + let resource!: string; + let name!: string; + + if (namespaced) { + switch (right.length) { + case 1: + name = right[0]; + // fallthrough + case 0: + resource = "namespaces"; // special case this due to `split` removing namespaces + break; + default: + [namespace, resource, name] = right; + break; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + apiVersion = left.pop()!; + apiGroup = left.join("/"); + } else { + switch (left.length) { + case 0: + throw new Error(`invalid apiPath: ${apiPath}`); + case 4: + [apiGroup, apiVersion, resource, name] = left; + break; + case 2: + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resource = left.pop()!; + // fallthrough + case 1: + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + apiVersion = left.pop()!; + apiGroup = ""; + break; + default: + /** + * Given that + * - `apiVersion` is `GROUP/VERSION` and + * - `VERSION` is `DNS_LABEL` which is /^[a-z0-9]((-[a-z0-9])|[a-z0-9])*$/i + * where length <= 63 + * - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253 + * + * There is no well defined selection from an array of items that were + * separated by '/' + * + * Solution is to create a heuristic. Namely: + * 1. if '.' in left[0] then apiGroup <- left[0] + * 2. if left[1] matches /^v[0-9]/ then apiGroup, apiVersion <- left[0], left[1] + * 3. otherwise assume apiVersion <- left[0] + * 4. always resource, name <- left[(0 or 1)+1..] + */ + if (left[0].includes(".") || left[1].match(/^v[0-9]/)) { + [apiGroup, apiVersion] = left; + resource = left.slice(2).join("/"); + } else { + apiGroup = ""; + apiVersion = left[0]; + [resource, name] = left.slice(1); + } + break; + } + } + + const apiVersionWithGroup = [apiGroup, apiVersion].filter(v => v).join("/"); + const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/"); + + if (!apiBase) { + throw new Error(`invalid apiPath: ${apiPath}`); + } + + return { + apiBase, + apiPrefix, apiGroup, + apiVersion, apiVersionWithGroup, + namespace, resource, name, + }; +} + +export function createKubeApiURL({ apiPrefix = "/apis", resource, apiVersion, name, namespace }: IKubeApiLinkRef): string { + const parts = [apiPrefix, apiVersion]; + + if (namespace) { + parts.push("namespaces", namespace); + } + + parts.push(resource); + + if (name) { + parts.push(name); + } + + return parts.join("/"); +} diff --git a/packages/core/src/common/k8s-api/kube-api.ts b/packages/core/src/common/k8s-api/kube-api.ts new file mode 100644 index 0000000000..c2a8906400 --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-api.ts @@ -0,0 +1,745 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Base class for building all kubernetes apis + +import { merge } from "lodash"; +import { stringify } from "querystring"; +import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; +import type { KubeObjectConstructor, KubeJsonApiDataFor, KubeObjectMetadata } from "./kube-object"; +import { KubeObject, KubeStatus, isKubeStatusData } from "./kube-object"; +import byline from "byline"; +import type { IKubeWatchEvent } from "./kube-watch-event"; +import type { KubeJsonApiData, KubeJsonApi } from "./kube-json-api"; +import type { Disposer } from "../utils"; +import { isDefined, noop, WrappedAbortController } from "../utils"; +import type { RequestInit, Response } from "node-fetch"; +import type { Patch } from "rfc6902"; +import assert from "assert"; +import type { PartialDeep } from "type-fest"; +import type { Logger } from "../logger"; +import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import autoRegistrationEmitterInjectable from "./api-manager/auto-registration-emitter.injectable"; +import type AbortController from "abort-controller"; +import { matches } from "lodash/fp"; + +/** + * The options used for creating a `KubeApi` + */ +export interface KubeApiOptions = KubeJsonApiDataFor> extends DerivedKubeApiOptions { + /** + * base api-path for listing all resources, e.g. "/api/v1/pods" + * + * Must be provided either here or under `objectConstructor.apiBase` + * @deprecated should be specified by `objectConstructor` + */ + apiBase?: string; + + /** + * The constructor for the kube objects returned from the API + */ + objectConstructor: KubeObjectConstructor; + + /** + * Must be provided either here or under `objectConstructor.namespaced` + * @deprecated should be specified by `objectConstructor` + */ + isNamespaced?: boolean; + + /** + * Must be provided either here or under `objectConstructor.kind` + * @deprecated should be specified by `objectConstructor` + */ + kind?: string; +} + +export interface DerivedKubeApiOptions { + /** + * If the API uses a different API endpoint (e.g. apiBase) depending on the cluster version, + * fallback API bases can be listed individually. + * The first (existing) API base is used in the requests, if apiBase is not found. + * This option only has effect if checkPreferredVersion is true. + */ + fallbackApiBases?: string[]; + + /** + * If `true` then will check all declared apiBases against the kube api server + * for the first accepted one. + */ + checkPreferredVersion?: boolean; + + /** + * The api instance to use for making requests + * + * @default apiKube + */ + request?: KubeJsonApi; +} + +export interface KubeApiQueryParams { + watch?: boolean | number; + resourceVersion?: string; + timeoutSeconds?: number; + limit?: number; // doesn't work with ?watch + continue?: string; // might be used with ?limit from second request + labelSelector?: string | string[]; // restrict list of objects by their labels, e.g. labelSelector: ["label=value"] + fieldSelector?: string | string[]; // restrict list of objects by their fields, e.g. fieldSelector: "field=name" +} + +export interface KubeApiListOptions { + namespace?: string; + reqInit?: RequestInit; +} + +export interface IKubePreferredVersion { + preferredVersion?: { + version: string; + }; +} + +export interface KubeApiResource { + categories?: string[]; + group?: string; + kind: string; + name: string; + namespaced: boolean; + shortNames?: string[]; + singularName: string; + storageVersionHash?: string; + verbs: string[]; + version?: string; +} + +export interface KubeApiResourceList { + apiVersion?: string; + groupVersion?: string; + kind?: string; + resources: KubeApiResource[]; +} + +export interface KubeApiResourceVersion { + groupVersion: string; + version: string; +} + +export interface KubeApiResourceVersionList { + apiVersion: string; + kind: string; + name: string; + preferredVersion: KubeApiResourceVersion; + versions: KubeApiResourceVersion[]; +} + +const not = (fn: (val: T) => boolean) => (val: T) => !(fn(val)); + +const getOrderedVersions = (list: KubeApiResourceVersionList): KubeApiResourceVersion[] => [ + list.preferredVersion, + ...list.versions.filter(not(matches(list.preferredVersion))), +]; + +export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background"; + +export type KubeApiWatchCallback = (data: IKubeWatchEvent | null, error: KubeStatus | Response | null | any) => void; + +export interface KubeApiWatchOptions> { + /** + * If the resource is namespaced then the default is `"default"` + */ + namespace?: string; + + /** + * This will be called when either an error occurs or some data is received + */ + callback?: KubeApiWatchCallback; + + /** + * This is a way of aborting the request + */ + abortController?: AbortController; + + /** + * The ID used for tracking within logs + */ + watchId?: string; + + /** + * @default false + */ + retry?: boolean; + + /** + * timeout in seconds + */ + timeout?: number; +} + +export type KubeApiPatchType = "merge" | "json" | "strategic"; + +const patchTypeHeaders: Record = { + "merge": "application/merge-patch+json", + "json": "application/json-patch+json", + "strategic": "application/strategic-merge-patch+json", +}; + +export interface ResourceDescriptor { + /** + * The name of the kubernetes resource + */ + name: string; + + /** + * The namespace that the resource lives in (if the resource is namespaced) + * + * Note: if not provided and the resource kind is namespaced, then this defaults to `"default"` + */ + namespace?: string; +} + +export interface DeleteResourceDescriptor extends ResourceDescriptor { + /** + * This determinines how child resources should be handled by kubernetes + * + * @default "Background" + */ + propagationPolicy?: PropagationPolicy; +} + +/** + * @deprecated In the new extension API, don't expose `KubeApi`'s constructor + */ +function legacyRegisterApi(api: KubeApi): void { + try { + /** + * This function throws if called in `main`, so the `try..catch` is to make sure that doesn't + * leak. + * + * However, we need this code to be run in `renderer` so that the auto registering of `KubeApi` + * instances still works. That auto registering never worked or was applicable in `main` because + * there is no "single cluster" on `main`. + * + * TODO: rearchitect this design pattern in the new extension API + */ + const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.renderer); + const autoRegistrationEmitter = di.inject(autoRegistrationEmitterInjectable); + + setImmediate(() => autoRegistrationEmitter.emit("kubeApi", api)); + } catch { + // ignore error + } +} + +export interface KubeApiDependencies { + readonly logger: Logger; + readonly maybeKubeApi: KubeJsonApi | undefined; +} + +export class KubeApi< + Object extends KubeObject = KubeObject, + Data extends KubeJsonApiDataFor = KubeJsonApiDataFor, +> { + readonly kind: string; + readonly apiVersion: string; + apiBase: string; + apiPrefix: string; + apiGroup: string; + apiVersionPreferred: string | undefined; + readonly apiResource: string; + readonly isNamespaced: boolean; + + public readonly objectConstructor: KubeObjectConstructor; + protected readonly request: KubeJsonApi; + protected readonly resourceVersions = new Map(); + protected readonly watchDisposer: Disposer | undefined; + private watchId = 1; + protected readonly doCheckPreferredVersion: boolean; + protected readonly fullApiPathname: string; + protected readonly fallbackApiBases: string[] | undefined; + + constructor(protected readonly dependencies: KubeApiDependencies, opts: KubeApiOptions) { + const { + objectConstructor, + request = this.dependencies.maybeKubeApi, + kind = objectConstructor.kind, + isNamespaced, + apiBase: fullApiPathname = objectConstructor.apiBase, + checkPreferredVersion: doCheckPreferredVersion = false, + fallbackApiBases, + } = opts; + + assert(fullApiPathname, "apiBase MUST be provied either via KubeApiOptions.apiBase or KubeApiOptions.objectConstructor.apiBase"); + assert(request, "request MUST be provided if not in a cluster page frame context"); + + const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(fullApiPathname); + + assert(kind, "kind MUST be provied either via KubeApiOptions.kind or KubeApiOptions.objectConstructor.kind"); + assert(apiPrefix, "apiBase MUST be parsable as a kubeApi selfLink style string"); + + this.doCheckPreferredVersion = doCheckPreferredVersion; + this.fallbackApiBases = fallbackApiBases; + this.fullApiPathname = fullApiPathname; + this.kind = kind; + this.isNamespaced = isNamespaced ?? objectConstructor.namespaced ?? false; + this.apiBase = apiBase; + this.apiPrefix = apiPrefix; + this.apiGroup = apiGroup; + this.apiVersion = apiVersion; + this.apiResource = resource; + this.request = request; + this.objectConstructor = objectConstructor; + legacyRegisterApi(this); + } + + get apiVersionWithGroup() { + return [this.apiGroup, this.apiVersionPreferred ?? this.apiVersion] + .filter(Boolean) + .join("/"); + } + + /** + * Returns the latest API prefix/group that contains the required resource. + * First tries fullApiPathname, then urls in order from fallbackApiBases. + */ + private async getLatestApiPrefixGroup() { + // Note that this.fullApiPathname is the "full" url, whereas this.apiBase is parsed + const rawApiBases = [ + this.fullApiPathname, + this.objectConstructor.apiBase, + ...this.fallbackApiBases ?? [], + ].filter(isDefined); + const apiBases = new Set(rawApiBases); + + for (const apiUrl of apiBases) { + try { + const { apiPrefix, apiGroup, resource } = parseKubeApi(apiUrl); + const list = await this.request.get(`${apiPrefix}/${apiGroup}`) as KubeApiResourceVersionList; + const resourceVersions = getOrderedVersions(list); + + for (const resourceVersion of resourceVersions) { + const { resources } = await this.request.get(`${apiPrefix}/${resourceVersion.groupVersion}`) as KubeApiResourceList; + + if (resources.some(({ name }) => name === resource)) { + return { + apiPrefix, + apiGroup, + apiVersionPreferred: resourceVersion.version, + }; + } + } + } catch (error) { + // Exception is ignored as we can try the next url + } + } + + throw new Error(`Can't find working API for the Kubernetes resource ${this.apiResource}`); + } + + protected async checkPreferredVersion() { + if (this.fallbackApiBases && !this.doCheckPreferredVersion) { + throw new Error("checkPreferredVersion must be enabled if fallbackApiBases is set in KubeApi"); + } + + if (this.doCheckPreferredVersion && this.apiVersionPreferred === undefined) { + const { apiPrefix, apiGroup, apiVersionPreferred } = await this.getLatestApiPrefixGroup(); + + this.apiPrefix = apiPrefix; + this.apiGroup = apiGroup; + this.apiVersionPreferred = apiVersionPreferred; + this.apiBase = this.computeApiBase(); + legacyRegisterApi(this); + } + } + + setResourceVersion(namespace = "", newVersion: string) { + this.resourceVersions.set(namespace, newVersion); + } + + getResourceVersion(namespace = "") { + return this.resourceVersions.get(namespace); + } + + async refreshResourceVersion(params?: KubeApiListOptions) { + return this.list(params, { limit: 1 }); + } + + private computeApiBase(): string { + return createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + }); + } + + /** + * This method differs from {@link formatUrlForNotListing} because this treats `""` as "all namespaces" + * NOTE: This is also useful for watching + * @param namespace The namespace to list in or `""` for all namespaces + */ + formatUrlForListing(namespace: string | undefined, query?: Partial) { + const resourcePath = createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + namespace: this.isNamespaced + ? namespace ?? "default" + : undefined, + }); + + return resourcePath + (query ? `?${stringify(this.normalizeQuery(query))}` : ""); + } + + /** + * Format a URL pathname and query for acting upon a specific resource. + */ + formatUrlForNotListing({ name, namespace }: Partial = {}) { + return createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + namespace: this.isNamespaced + ? namespace || "default" + : undefined, + name, + }); + } + + /** + * @deprecated use {@link formatUrlForNotListing} or {@link formatUrlForListing} instead + */ + getUrl(resource?: Partial, query?: Partial) { + if (query) { + return this.formatUrlForListing(resource?.namespace, query); + } + + return this.formatUrlForNotListing(resource); + } + + protected normalizeQuery(query: Partial = {}) { + if (query.labelSelector) { + query.labelSelector = [query.labelSelector].flat().join(","); + } + + if (query.fieldSelector) { + query.fieldSelector = [query.fieldSelector].flat().join(","); + } + + return query; + } + + protected parseResponse(data: unknown, namespace?: string): Object | Object[] | null { + if (!data) { + return null; + } + + const KubeObjectConstructor = this.objectConstructor; + + // process items list response, check before single item since there is overlap + if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) { + const { apiVersion, items, metadata } = data; + + this.setResourceVersion(namespace, metadata.resourceVersion); + this.setResourceVersion("", metadata.resourceVersion); + + return items + .map((item) => { + if (item.metadata) { + this.ensureMetadataSelfLink(item.metadata); + } else { + return undefined; + } + + const object = new KubeObjectConstructor({ + ...(item as Data), + kind: this.kind, + apiVersion, + }); + + return object; + }) + .filter(isDefined); + } + + // process a single item + if (KubeObject.isJsonApiData(data)) { + this.ensureMetadataSelfLink(data.metadata); + + return new KubeObjectConstructor(data as never); + } + + // custom apis might return array for list response, e.g. users, groups, etc. + if (Array.isArray(data)) { + return data.map(data => { + this.ensureMetadataSelfLink(data.metadata); + + return new KubeObjectConstructor(data); + }); + } + + return null; + } + + private ensureMetadataSelfLink(metadata: T): asserts metadata is T & { selfLink: string } { + metadata.selfLink ||= createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + namespace: metadata.namespace, + name: metadata.name, + }); + } + + async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: KubeApiQueryParams): Promise { + await this.checkPreferredVersion(); + + const url = this.formatUrlForListing(namespace); + const res = await this.request.get(url, { query }, reqInit); + const parsed = this.parseResponse(res, namespace); + + if (Array.isArray(parsed)) { + return parsed; + } + + if (!parsed) { + return null; + } + + throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); + } + + async get(desc: ResourceDescriptor, query?: KubeApiQueryParams): Promise { + await this.checkPreferredVersion(); + + const url = this.formatUrlForNotListing(desc); + const res = await this.request.get(url, { query }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; + } + + async create({ name, namespace }: Partial, partialData?: PartialDeep): Promise { + await this.checkPreferredVersion(); + + const apiUrl = this.formatUrlForNotListing({ namespace }); + const data = merge(partialData, { + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace, + }, + }); + const res = await this.request.post(apiUrl, { data }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; + } + + async update({ name, namespace }: ResourceDescriptor, data: PartialDeep): Promise { + await this.checkPreferredVersion(); + const apiUrl = this.formatUrlForNotListing({ namespace, name }); + + const res = await this.request.put(apiUrl, { + data: merge(data, { + metadata: { + name, + namespace, + }, + }), + }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; + } + + async patch(desc: ResourceDescriptor, data: PartialDeep): Promise; + async patch(desc: ResourceDescriptor, data: PartialDeep, strategy: "strategic" | "merge"): Promise; + async patch(desc: ResourceDescriptor, data: Patch, strategy: "json"): Promise; + async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType): Promise; + async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType = "strategic"): Promise { + await this.checkPreferredVersion(); + const apiUrl = this.formatUrlForNotListing(desc); + + const res = await this.request.patch(apiUrl, { data }, { + headers: { + "content-type": patchTypeHeaders[strategy], + }, + }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PATCH request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; + } + + async delete({ propagationPolicy = "Background", ...desc }: DeleteResourceDescriptor) { + await this.checkPreferredVersion(); + const apiUrl = this.formatUrlForNotListing(desc); + + return this.request.del(apiUrl, { + query: { + propagationPolicy, + }, + }); + } + + getWatchUrl(namespace?: string, query: KubeApiQueryParams = {}) { + return this.formatUrlForListing(namespace, { + watch: 1, + resourceVersion: this.getResourceVersion(namespace), + ...query, + }); + } + + watch(opts?: KubeApiWatchOptions): Disposer { + let errorReceived = false; + let timedRetry: NodeJS.Timeout; + const { + namespace, + callback = noop as KubeApiWatchCallback, + retry = false, + timeout = 600, + watchId = `${this.kind.toLowerCase()}-${this.watchId++}`, + } = opts ?? {}; + + // Create AbortController for this request + const abortController = new WrappedAbortController(opts?.abortController); + + abortController.signal.addEventListener("abort", () => { + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) aborted ${watchUrl}`); + clearTimeout(timedRetry); + }); + + const requestParams = timeout ? { query: { timeoutSeconds: timeout }} : {}; + const watchUrl = this.getWatchUrl(namespace); + const responsePromise = this.request.getResponse(watchUrl, requestParams, { + signal: abortController.signal, + }); + + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) ${retry === true ? "retried" : "started"} ${watchUrl}`); + + responsePromise + .then(response => { + // True if the current watch request was retried + let requestRetried = false; + + if (!response.ok) { + this.dependencies.logger.warn(`[KUBE-API] watch (${watchId}) error response ${watchUrl}`, { status: response.status }); + + return callback(null, response); + } + + // Add mechanism to retry in case timeoutSeconds is set but the watch wasn't timed out. + // This can happen if e.g. network is offline and AWS NLB is used. + if (timeout) { + setTimeout(() => { + // We only retry if we haven't retried, haven't aborted and haven't received k8s error + if (requestRetried || abortController.signal.aborted || errorReceived) { + return; + } + + // Close current request + abortController.abort(); + + this.dependencies.logger.info(`[KUBE-API] Watch timeout set, but not retried, retrying now`); + + requestRetried = true; + + // Clearing out any possible timeout, although we don't expect this to be set + clearTimeout(timedRetry); + this.watch({ ...opts, namespace, callback, watchId, retry: true }); + // We wait longer than the timeout, as we expect the request to be retried with timeoutSeconds + }, timeout * 1000 * 1.1); + } + + if (!response.body || !response.body.readable) { + if (!response.body) { + this.dependencies.logger.warn(`[KUBE-API]: watch (${watchId}) did not return a body`); + } + requestRetried = true; + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + this.watch({ ...opts, namespace, callback, watchId, retry: true }); + }, 1000); + + return; + } + + for (const eventName of ["end", "close", "error"]) { + response.body.on(eventName, () => { + // We only retry if we haven't retried, haven't aborted and haven't received k8s error + // kubernetes errors (=errorReceived set) should be handled in a callback + if (requestRetried || abortController.signal.aborted || errorReceived) { + return; + } + + this.dependencies.logger.info(`[KUBE-API] watch (${watchId}) ${eventName} ${watchUrl}`); + + requestRetried = true; + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + this.watch({ ...opts, namespace, callback, watchId, retry: true }); + }, 1000); + }); + } + + byline(response.body).on("data", (line) => { + try { + const event = JSON.parse(line) as IKubeWatchEvent; + + if (event.type === "ERROR" && isKubeStatusData(event.object)) { + errorReceived = true; + + return callback(null, new KubeStatus(event.object)); + } + + this.modifyWatchEvent(event); + callback(event, null); + } catch (ignore) { + // ignore parse errors + } + }); + }) + .catch(error => { + if (!abortController.signal.aborted) { + this.dependencies.logger.error(`[KUBE-API] watch (${watchId}) threw ${watchUrl}`, error); + } + callback(null, error); + }); + + return () => { + abortController.abort(); + }; + } + + protected modifyWatchEvent(event: IKubeWatchEvent>) { + if (event.type === "ERROR") { + return; + } + + this.ensureMetadataSelfLink(event.object.metadata); + + const { namespace, resourceVersion } = event.object.metadata; + + assert(resourceVersion, "watch events failed to return resourceVersion from kube api"); + + this.setResourceVersion(namespace, resourceVersion); + this.setResourceVersion("", resourceVersion); + } +} diff --git a/packages/core/src/common/k8s-api/kube-api/get-kube-api-from-path.injectable.ts b/packages/core/src/common/k8s-api/kube-api/get-kube-api-from-path.injectable.ts new file mode 100644 index 0000000000..669185684f --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-api/get-kube-api-from-path.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { parseKubeApi } from "../kube-api-parse"; +import { kubeApiInjectionToken } from "./kube-api-injection-token"; +import type { KubeApi } from "../kube-api"; + +const getKubeApiFromPathInjectable = getInjectable({ + id: "get-kube-api-from-path", + + instantiate: (di) => { + const kubeApis = di.injectMany(kubeApiInjectionToken); + + return (apiPath: string) => { + const parsed = parseKubeApi(apiPath); + + const kubeApi = kubeApis.find((api) => api.apiBase === parsed.apiBase); + + return (kubeApi as KubeApi) || undefined; + }; + }, +}); + +export default getKubeApiFromPathInjectable; diff --git a/packages/core/src/common/k8s-api/kube-api/kube-api-injection-token.ts b/packages/core/src/common/k8s-api/kube-api/kube-api-injection-token.ts new file mode 100644 index 0000000000..92b6636274 --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-api/kube-api-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { KubeApi } from "../kube-api"; + +export const kubeApiInjectionToken = getInjectionToken>({ + id: "kube-api-injection-token", +}); diff --git a/packages/core/src/common/k8s-api/kube-json-api.ts b/packages/core/src/common/k8s-api/kube-json-api.ts new file mode 100644 index 0000000000..16ca5cda70 --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-json-api.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { JsonApiData, JsonApiError } from "./json-api"; +import { JsonApi } from "./json-api"; +import type { Response } from "node-fetch"; +import type { KubeJsonApiObjectMetadata } from "./kube-object"; + +export interface KubeJsonApiListMetadata { + resourceVersion: string; + selfLink?: string; +} + +export interface KubeJsonApiDataList { + kind: string; + apiVersion: string; + items: T[]; + metadata: KubeJsonApiListMetadata; +} + +export interface KubeJsonApiData< + Metadata extends KubeJsonApiObjectMetadata = KubeJsonApiObjectMetadata, + Status = unknown, + Spec = unknown, +> extends JsonApiData { + kind: string; + apiVersion: string; + metadata: Metadata; + status?: Status; + spec?: Spec; + [otherKeys: string]: unknown; +} + +export interface KubeJsonApiError extends JsonApiError { + code: number; + status: string; + message?: string; + reason: string; + details: { + name: string; + kind: string; + }; +} + +export class KubeJsonApi extends JsonApi { + protected parseError(error: KubeJsonApiError | string, res: Response): string[] { + if (typeof error === "string") { + return [error]; + } + + const { status, reason, message } = error; + + if (status && reason) { + return [message || `${status}: ${reason}`]; + } + + return super.parseError(error, res); + } +} diff --git a/packages/core/src/common/k8s-api/kube-object-status.ts b/packages/core/src/common/k8s-api/kube-object-status.ts new file mode 100644 index 0000000000..200bf35b5d --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-object-status.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface KubeObjectStatus { + level: KubeObjectStatusLevel; + text: string; + timestamp?: string; +} + +export enum KubeObjectStatusLevel { + INFO = 1, + WARNING = 2, + CRITICAL = 3, +} diff --git a/packages/core/src/common/k8s-api/kube-object.store.ts b/packages/core/src/common/k8s-api/kube-object.store.ts new file mode 100644 index 0000000000..ee6a32265e --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-object.store.ts @@ -0,0 +1,539 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, computed, makeObservable, observable, reaction } from "mobx"; +import type { Disposer } from "../utils"; +import { waitUntilDefined, autoBind, includes, rejectPromiseBy } from "../utils"; +import type { KubeJsonApiDataFor, KubeObject } from "./kube-object"; +import { KubeStatus } from "./kube-object"; +import type { IKubeWatchEvent } from "./kube-watch-event"; +import { ItemStore } from "../item.store"; +import type { KubeApiQueryParams, KubeApi, KubeApiWatchCallback } from "./kube-api"; +import { parseKubeApi } from "./kube-api-parse"; +import type { RequestInit } from "node-fetch"; +import type { Patch } from "rfc6902"; +import type { Logger } from "../logger"; +import assert from "assert"; +import type { PartialDeep } from "type-fest"; +import { entries } from "../utils/objects"; +import AbortController from "abort-controller"; +import type { ClusterContext } from "../../renderer/cluster-frame-context/cluster-frame-context"; + +export type OnLoadFailure = (error: unknown) => void; + +export interface KubeObjectStoreLoadingParams { + namespaces: string[]; + reqInit?: RequestInit; + + /** + * A function that is called when listing fails. If set then blocks errors + * being rejected with + */ + onLoadFailure?: OnLoadFailure; +} + +export interface KubeObjectStoreLoadAllParams { + namespaces?: string[]; + merge?: boolean; + reqInit?: RequestInit; + + /** + * A function that is called when listing fails. If set then blocks errors + * being rejected with + */ + onLoadFailure?: OnLoadFailure; +} + +export interface KubeObjectStoreSubscribeParams { + /** + * A function that is called when listing fails. If set then blocks errors + * being rejected with + */ + onLoadFailure?: OnLoadFailure; + + /** + * An optional parent abort controller + */ + abortController?: AbortController; +} + +export interface MergeItemsOptions { + merge?: boolean; + updateStore?: boolean; + sort?: boolean; + filter?: boolean; + namespaces: string[]; +} + +export interface StatusProvider { + getStatuses(items: K[]): Record; +} + +export interface KubeObjectStoreOptions { + limit?: number; + bufferSize?: number; +} + +export type KubeApiDataFrom = A extends KubeApi + ? D extends KubeJsonApiDataFor + ? D + : never + : never; + +export type JsonPatch = Patch; + +export interface KubeObjectStoreDependencies { + readonly context: ClusterContext; + readonly logger: Logger; +} + +export abstract class KubeObjectStore< + K extends KubeObject = KubeObject, + A extends KubeApi = KubeApi>, + D extends KubeJsonApiDataFor = KubeApiDataFrom, +> extends ItemStore { + public readonly limit: number | undefined; + public readonly bufferSize: number; + + private readonly loadedNamespaces = observable.box(); + + constructor( + protected readonly dependencies: KubeObjectStoreDependencies, + public readonly api: A, + opts?: KubeObjectStoreOptions, + ) { + super(); + this.limit = opts?.limit; + this.bufferSize = opts?.bufferSize ?? 50_000; + + makeObservable(this); + autoBind(this); + this.bindWatchEventsUpdater(); + } + + // TODO: Circular dependency: KubeObjectStore -> ClusterFrameContext -> NamespaceStore -> KubeObjectStore + @computed get contextItems(): K[] { + const namespaces = this.dependencies.context.contextNamespaces; + + return this.items.filter(item => { + const itemNamespace = item.getNs(); + + return !itemNamespace /* cluster-wide */ || namespaces.includes(itemNamespace); + }); + } + + getTotalCount(): number { + return this.contextItems.length; + } + + get query(): KubeApiQueryParams { + const { limit } = this; + + if (!limit) { + return {}; + } + + return { limit }; + } + + getAllByNs(namespace: string | string[], strict = false): K[] { + const namespaces = [namespace].flat(); + + if (namespaces.length) { + return this.items.filter(item => includes(namespaces, item.getNs())); + } + + if (!strict) { + return this.items; + } + + return []; + } + + getById(id: string): K | undefined { + return this.items.find(item => item.getId() === id); + } + + getByName(name: string, namespace?: string): K | undefined { + return this.items.find(item => { + return item.getName() === name && ( + namespace ? item.getNs() === namespace : true + ); + }); + } + + getByPath(path: string): K | undefined { + return this.items.find(item => item.selfLink === path); + } + + getByLabel(labels: string[] | Partial>): K[] { + if (Array.isArray(labels)) { + return this.items.filter((item: K) => { + const itemLabels = item.getLabels(); + + return labels.every(label => itemLabels.includes(label)); + }); + } else { + return this.items.filter((item: K) => { + const itemLabels = item.metadata.labels || {}; + + return entries(labels) + .every(([key, value]) => itemLabels[key] === value); + }); + } + } + + protected async loadItems({ namespaces, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams): Promise { + const isLoadingAll = this.dependencies.context.isLoadingAll(namespaces); + + if (!this.api.isNamespaced || isLoadingAll) { + if (this.api.isNamespaced) { + this.loadedNamespaces.set([]); + } + + const res = this.api.list({ reqInit }, this.query); + + if (onLoadFailure) { + try { + return await res ?? []; + } catch (error) { + onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: error })); + + // reset the store because we are loading all, so that nothing is displayed + this.items.clear(); + this.selectedItemsIds.clear(); + + return []; + } + } + + return await res ?? []; + } + + this.loadedNamespaces.set(namespaces); + + const results = await Promise.allSettled( + namespaces.map(namespace => this.api.list({ namespace, reqInit }, this.query)), + ); + const res: K[] = []; + + for (const result of results) { + switch (result.status) { + case "fulfilled": + res.push(...result.value ?? []); + break; + + case "rejected": + if (onLoadFailure) { + onLoadFailure(new Error(`Failed to load ${this.api.apiBase}`, { cause: result.reason })); + } else { + // if onLoadFailure is not provided then preserve old behaviour + throw result.reason; + } + } + } + + return res; + } + + protected filterItemsOnLoad(items: K[]) { + return items; + } + + @action + async loadAll({ namespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { + namespaces ??= this.dependencies.context.contextNamespaces; + this.isLoading = true; + + try { + const items = await this.loadItems({ namespaces, reqInit, onLoadFailure }); + + this.mergeItems(items, { merge, namespaces }); + + this.isLoaded = true; + this.failedLoading = false; + + return items; + } catch (error) { + console.warn("[KubeObjectStore] loadAll failed", this.api.apiBase, error); + this.resetOnError(error); + this.failedLoading = true; + } finally { + this.isLoading = false; + } + + return undefined; + } + + @action + async reloadAll(opts: { force?: boolean; namespaces?: string[]; merge?: boolean } = {}): Promise { + const { force = false, ...loadingOptions } = opts; + + if (this.isLoading || (this.isLoaded && !force)) { + return undefined; + } + + return this.loadAll(loadingOptions); + } + + @action + protected mergeItems(partialItems: K[], { merge = true, updateStore = true, sort = true, filter = true, namespaces }: MergeItemsOptions): K[] { + let items = partialItems; + + // update existing items + if (merge && this.api.isNamespaced) { + const ns = new Set(namespaces); + + items = [ + ...this.items.filter(item => !ns.has(item.getNs() as string)), + ...partialItems, + ]; + } + + if (filter) items = this.filterItemsOnLoad(items); + if (sort) items = this.sortItems(items); + if (updateStore) this.items.replace(items); + + return items; + } + + protected resetOnError(error: any) { + if (error) this.reset(); + } + + protected async loadItem(params: { name: string; namespace?: string }): Promise { + return this.api.get(params); + } + + @action + async load(params: { name: string; namespace?: string }): Promise { + const { name, namespace } = params; + let item: K | null | undefined = this.getByName(name, namespace); + + if (!item) { + item = await this.loadItem(params); + assert(item, "Failed to load item from kube"); + const newItems = this.sortItems([...this.items, item]); + + this.items.replace(newItems); + } + + return item; + } + + @action + async loadFromPath(resourcePath: string) { + const { namespace, name } = parseKubeApi(resourcePath); + + assert(name, "name must be part of resourcePath"); + + return this.load({ name, namespace }); + } + + protected async createItem(params: { name: string; namespace?: string }, data?: PartialDeep): Promise { + return this.api.create(params, data); + } + + async create(params: { name: string; namespace?: string }, data?: PartialDeep): Promise { + const newItem = await this.createItem(params, data); + + assert(newItem, "Failed to create item from kube"); + const items = this.sortItems([...this.items, newItem]); + + this.items.replace(items); + + return newItem; + } + + private postUpdate(newItem: K): K { + const index = this.items.findIndex(item => item.getId() === newItem.getId()); + + if (index < 0) { + this.items.push(newItem); + } else { + this.items[index] = newItem; + } + + return newItem; + } + + async patch(item: K, patch: JsonPatch): Promise { + const rawItem = await this.api.patch( + { + name: item.getName(), namespace: item.getNs(), + }, + patch, + "json", + ); + + assert(rawItem, `Failed to patch ${item.getScopedName()} of ${item.kind} ${item.apiVersion}`); + + return this.postUpdate(rawItem); + } + + async update(item: K, data: PartialDeep): Promise { + const rawItem = await this.api.update( + { + name: item.getName(), + namespace: item.getNs(), + }, + data, + ); + + assert(rawItem, `Failed to update ${item.getScopedName()} of ${item.kind} ${item.apiVersion}`); + + return this.postUpdate(rawItem); + } + + async remove(item: K) { + await this.api.delete({ name: item.getName(), namespace: item.getNs() }); + this.selectedItemsIds.delete(item.getId()); + } + + async removeSelectedItems() { + await Promise.all(this.selectedItems.map(this.remove)); + } + + async removeItems(items: K[]) { + await Promise.all(items.map(this.remove)); + } + + // collect items from watch-api events to avoid UI blowing up with huge streams of data + protected readonly eventsBuffer = observable.array>([], { deep: false }); + + protected bindWatchEventsUpdater(delay = 1000) { + reaction(() => [...this.eventsBuffer], this.updateFromEventsBuffer, { + delay, + }); + } + + subscribe({ onLoadFailure, abortController = new AbortController() }: KubeObjectStoreSubscribeParams = {}): Disposer { + if (this.api.isNamespaced) { + void (async () => { + try { + const loadedNamespaces = await Promise.race([ + rejectPromiseBy(abortController.signal), + waitUntilDefined(() => this.loadedNamespaces.get()), + ]); + + if (this.dependencies.context.isGlobalWatchEnabled() && loadedNamespaces.length === 0) { + this.watchNamespace("", abortController, { onLoadFailure }); + } else { + for (const namespace of loadedNamespaces) { + this.watchNamespace(namespace, abortController, { onLoadFailure }); + } + } + } catch (error) { + console.error(`[KUBE-OBJECT-STORE]: failed to subscribe to ${this.api.apiBase}`, error); + } + })(); + } else { + this.watchNamespace("", abortController, { onLoadFailure }); + } + + return () => abortController.abort(); + } + + private watchNamespace(namespace: string, abortController: AbortController, opts: KubeObjectStoreSubscribeParams) { + if (!this.api.getResourceVersion(namespace)) { + return; + } + + let timedRetry: NodeJS.Timeout; + const startNewWatch = () => this.api.watch({ + namespace, + abortController, + callback, + }); + + const signal = abortController.signal; + + const callback: KubeApiWatchCallback = (data, error) => { + if (!this.isLoaded || error?.type === "aborted") return; + + if (error instanceof Response) { + if (error.status === 404 || error.status === 401) { + // api has gone, or credentials are not permitted, let's not retry + return; + } + + // not sure what to do, best to retry + clearTimeout(timedRetry); + timedRetry = setTimeout(startNewWatch, 5000); + } else if (error instanceof KubeStatus && error.code === 410) { + clearTimeout(timedRetry); + // resourceVersion has gone, let's try to reload + timedRetry = setTimeout(() => { + ( + namespace + ? this.loadAll({ namespaces: [namespace], reqInit: { signal }, ...opts }) + : this.loadAll({ merge: false, reqInit: { signal }, ...opts }) + ).then(startNewWatch); + }, 1000); + } else if (error) { // not sure what to do, best to retry + clearTimeout(timedRetry); + timedRetry = setTimeout(startNewWatch, 5000); + } + + if (data) { + this.eventsBuffer.push(data); + } + }; + + signal.addEventListener("abort", () => clearTimeout(timedRetry)); + startNewWatch(); + } + + @action + protected updateFromEventsBuffer() { + const items = this.getItems(); + + for (const event of this.eventsBuffer.clear()) { + if (event.type === "ERROR") { + continue; + } + + try { + const { type, object } = event; + + if (!object.metadata?.uid) { + this.dependencies.logger.warn("[KUBE-STORE]: watch event did not have defined .metadata.uid, skipping", { event }); + // Other parts of the code will break if this happens + continue; + } + + const index = items.findIndex(item => item.getId() === object.metadata.uid); + const item = items[index]; + + switch (type) { + case "ADDED": + + // fallthrough + case "MODIFIED": { + const newItem = new this.api.objectConstructor(object); + + if (!item) { + items.push(newItem); + } else { + items[index] = newItem; + } + + break; + } + case "DELETED": + if (item) { + items.splice(index, 1); + } + break; + } + } catch (error) { + this.dependencies.logger.error("[KUBE-STORE]: failed to handle event from watch buffer", { error, event }); + } + } + + // update items + this.items.replace(this.sortItems(items.slice(-this.bufferSize))); + } +} diff --git a/packages/core/src/common/k8s-api/kube-object.ts b/packages/core/src/common/k8s-api/kube-object.ts new file mode 100644 index 0000000000..d88c19db89 --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-object.ts @@ -0,0 +1,694 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Base class for all kubernetes objects + +import moment from "moment"; +import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata } from "./kube-json-api"; +import { autoBind, formatDuration, hasOptionalTypedProperty, hasTypedProperty, isObject, isString, isNumber, bindPredicate, isTypedArray, isRecord, json } from "../utils"; +import type { ItemObject } from "../item.store"; +import type { Patch } from "rfc6902"; +import assert from "assert"; +import type { JsonObject } from "type-fest"; +import requestKubeObjectPatchInjectable from "./endpoints/resource-applier.api/request-patch.injectable"; +import { apiKubeInjectionToken } from "./api-kube"; +import requestKubeObjectCreationInjectable from "./endpoints/resource-applier.api/request-update.injectable"; +import { dump } from "js-yaml"; +import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; + +export type KubeJsonApiDataFor = K extends KubeObject + ? KubeJsonApiData + : never; + +export interface KubeObjectConstructorData { + readonly kind?: string; + readonly namespaced?: boolean; + readonly apiBase?: string; +} + +export type KubeObjectConstructor = (new (data: Data) => K) & KubeObjectConstructorData; + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export type KubeTemplateObjectMetadata = Pick, "annotations" | "finalizers" | "generateName" | "labels" | "ownerReferences"> & { + name?: string; + namespace?: ScopedNamespace; +}; + +export interface BaseKubeJsonApiObjectMetadata { + /** + * Annotations is an unstructured key value map stored with a resource that may be set by + * external tools to store and retrieve arbitrary metadata. They are not queryable and should be + * preserved when modifying objects. + * + * More info: http://kubernetes.io/docs/user-guide/annotations + */ + annotations?: Partial>; + + /** + * The name of the cluster which the object belongs to. This is used to distinguish resources + * with same name and namespace in different clusters. This field is not set anywhere right now + * and apiserver is going to ignore it if set in create or update request. + */ + clusterName?: string; + + /** + * CreationTimestamp is a timestamp representing the server time when this object was created. It + * is not guaranteed to be set in happens-before order across separate operations. Clients may + * not set this value. It is represented in RFC3339 form and is in UTC. Populated by the system. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly creationTimestamp?: string; + + /** + * Number of seconds allowed for this object to gracefully terminate before it will be removed + * from the system. Only set when deletionTimestamp is also set. May only be shortened. + */ + readonly deletionGracePeriodSeconds?: number; + + /** + * DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field + * is set by the server when a graceful deletion is requested by the user, and is not directly + * settable by a client. The resource is expected to be deleted (no longer visible from resource + * lists, and not reachable by name) after the time in this field, once the finalizers list is + * empty. As long as the finalizers list contains items, deletion is blocked. Once the + * `deletionTimestamp` is set, this value may not be unset or be set further into the future, + * although it may be shortened or the resource may be deleted prior to this time. For example, + * a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a + * graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet + * will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the + * pod from the API. In the presence of network partitions, this object may still exist after + * this timestamp, until an administrator or automated process can determine the resource is + * fully terminated. If not set, graceful deletion of the object has not been requested. + * Populated by the system when a graceful deletion is requested. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly deletionTimestamp?: string; + + /** + * Must be empty before the object is deleted from the registry. Each entry is an identifier for + * the responsible component that will remove the entry from the list. If the deletionTimestamp + * of the object is non-nil, entries in this list can only be removed. Finalizers may be + * processed and removed in any order. Order is NOT enforced because it introduces significant + * risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder + * it. If the finalizer list is processed in order, then this can lead to a situation in which + * the component responsible for the first finalizer in the list is waiting for a signal (field + * value, external system, or other) produced by a component responsible for a finalizer later in + * the list, resulting in a deadlock. Without enforced ordering finalizers are free to order + * amongst themselves and are not vulnerable to ordering changes in the list. + */ + finalizers?: string[]; + + /** + * GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the + * Name field has not been provided. If this field is used, the name returned to the client will + * be different than the name passed. This value will also be combined with a unique suffix. The + * provided value has the same validation rules as the Name field, and may be truncated by the + * length of the suffix required to make the value unique on the server. If this field is + * specified and the generated name exists, the server will NOT return a 409 - instead, it will + * either return 201 Created or 500 with Reason ServerTimeout indicating a unique name could not + * be found in the time allotted, and the client should retry (optionally after the time indicated + * in the Retry-After header). Applied only if Name is not specified. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency + */ + generateName?: string; + + /** + * A sequence number representing a specific generation of the desired state. Populated by the + * system. + */ + readonly generation?: number; + + /** + * Map of string keys and values that can be used to organize and categorize (scope and select) + * objects. May match selectors of replication controllers and services. + * + * More info: http://kubernetes.io/docs/user-guide/labels + */ + labels?: Partial>; + + /** + * ManagedFields maps workflow-id and version to the set of fields that are managed by that + * workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set + * or understand this field. A workflow can be the user's name, a controller's name, or the name + * of a specific apply path like "ci-cd". The set of fields is always in the version that the + * workflow used when modifying the object. + */ + managedFields?: unknown[]; + + /** + * Name must be unique within a namespace. Is required when creating resources, although some + * resources may allow a client to request the generation of an appropriate name automatically. + * Name is primarily intended for creation idempotence and configuration definition. + * + * More info: http://kubernetes.io/docs/user-guide/identifiers#names + */ + readonly name: string; + + /** + * Namespace defines the space within which each name must be unique. An empty namespace is + * equivalent to the "default" namespace, but "default" is the canonical representation. Not all + * objects are required to be scoped to a namespace - the value of this field for those objects + * will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces + */ + readonly namespace?: ScopedNamespace; + + /** + * List of objects depended by this object. If ALL objects in the list have been deleted, this + * object will be garbage collected. If this object is managed by a controller, then an entry in + * this list will point to this controller, with the controller field set to true. There cannot + * be more than one managing controller. + */ + ownerReferences?: OwnerReference[]; + + /** + * An opaque value that represents the internal version of this object that can be used by + * clients to determine when objects have changed. May be used for optimistic concurrency, change + * detection, and the watch operation on a resource or set of resources. Clients must treat these + * values as opaque and passed unmodified back to the server. They may only be valid for a + * particular resource or set of resources. Populated by the system. Value must be treated as + * opaque by clients. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + */ + readonly resourceVersion?: string; + + /** + * SelfLink is a URL representing this object. Populated by the system. + */ + readonly selfLink?: string; + + /** + * UID is the unique in time and space value for this object. It is typically generated by the + * server on successful creation of a resource and is not allowed to change on PUT operations. + * Populated by the system. + * + * More info: http://kubernetes.io/docs/user-guide/identifiers#uids + */ + readonly uid?: string; + + [key: string]: unknown; +} + +export type KubeJsonApiObjectMetadata = BaseKubeJsonApiObjectMetadata & ( + Namespaced extends KubeObjectScope.Namespace + ? { readonly namespace: string } + : {} +); + +export type KubeObjectMetadata = KubeJsonApiObjectMetadata & { + readonly selfLink: string; + readonly uid: string; + readonly name: string; + readonly resourceVersion: string; +}; + +export type NamespaceScopedMetadata = KubeObjectMetadata; +export type ClusterScopedMetadata = KubeObjectMetadata; + +export interface KubeStatusData { + kind: string; + apiVersion: string; + code: number; + message?: string; + reason?: string; +} + +export function isKubeStatusData(object: unknown): object is KubeStatusData { + return isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "code", isNumber) + && hasOptionalTypedProperty(object, "message", isString) + && hasOptionalTypedProperty(object, "reason", isString) + && object.kind === "Status"; +} + +export class KubeStatus { + public readonly kind = "Status"; + public readonly apiVersion: string; + public readonly code: number; + public readonly message: string; + public readonly reason: string; + + constructor(data: KubeStatusData) { + this.apiVersion = data.apiVersion; + this.code = data.code; + this.message = data.message || ""; + this.reason = data.reason || ""; + } +} + +export interface BaseKubeObjectCondition { + /** + * Last time the condition transit from one status to another. + * + * @type Date + */ + lastTransitionTime?: string; + /** + * A human readable message indicating details about last transition. + */ + message?: string; + /** + * brief (usually one word) readon for the condition's last transition. + */ + reason?: string; + /** + * Status of the condition + */ + status: "True" | "False" | "Unknown"; + /** + * Type of the condition + */ + type: string; +} + +export interface KubeObjectStatus { + conditions?: BaseKubeObjectCondition[]; +} + +export type KubeMetaField = keyof KubeJsonApiObjectMetadata; + +export class KubeCreationError extends Error { + constructor(message: string, public data: any) { + super(message); + } +} + +export type LabelMatchExpression = { + /** + * The label key that the selector applies to. + */ + key: string; +} & ( + { + /** + * This represents the key's relationship to a set of values. + */ + operator: "Exists" | "DoesNotExist"; + values?: undefined; + } + | + { + operator: "In" | "NotIn"; + /** + * The set of values for to match according to the operator for the label. + */ + values: string[]; + } +); + +export interface Toleration { + key?: string; + operator?: string; + effect?: string; + value?: string; + tolerationSeconds?: number; +} + +export interface ObjectReference { + apiVersion?: string; + fieldPath?: string; + kind?: string; + name: string; + namespace?: string; + resourceVersion?: string; + uid?: string; +} + +export interface LocalObjectReference { + name: string; +} + +export interface TypedLocalObjectReference { + apiGroup?: string; + kind: string; + name: string; +} + +export interface NodeAffinity { + nodeSelectorTerms?: LabelSelector[]; + weight: number; + preference: LabelSelector; +} + +export interface PodAffinity { + labelSelector: LabelSelector; + topologyKey: string; +} + +export interface SpecificAffinity { + requiredDuringSchedulingIgnoredDuringExecution?: T[]; + preferredDuringSchedulingIgnoredDuringExecution?: T[]; +} + +export interface Affinity { + nodeAffinity?: SpecificAffinity; + podAffinity?: SpecificAffinity; + podAntiAffinity?: SpecificAffinity; +} + +export interface LabelSelector { + matchLabels?: Partial>; + matchExpressions?: LabelMatchExpression[]; +} + +export enum KubeObjectScope { + Namespace, + Cluster, +} +export type ScopedNamespace = ( + Namespaced extends KubeObjectScope.Namespace + ? string + : Namespaced extends KubeObjectScope.Cluster + ? undefined + : string | undefined +); + +const resourceApplierAnnotationsForFiltering = [ + "kubectl.kubernetes.io/last-applied-configuration", +]; + +const filterOutResourceApplierAnnotations = (label: string) => !resourceApplierAnnotationsForFiltering.some(key => label.startsWith(key)); + +export class KubeObject< + Metadata extends KubeObjectMetadata = KubeObjectMetadata, + Status = unknown, + Spec = unknown, +> implements ItemObject { + static readonly kind?: string; + static readonly namespaced?: boolean; + static readonly apiBase?: string; + + apiVersion!: string; + kind!: string; + metadata!: Metadata; + status?: Status; + spec!: Spec; + + static create< + Metadata extends KubeObjectMetadata = KubeObjectMetadata, + Status = unknown, + Spec = unknown, + >(data: KubeJsonApiData) { + return new KubeObject(data); + } + + static isNonSystem(item: KubeJsonApiData | KubeObject, unknown, unknown>) { + return !item.metadata.name?.startsWith("system:"); + } + + static isJsonApiData(object: unknown): object is KubeJsonApiData { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); + } + + static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata { + return ( + isObject(object) + && hasOptionalTypedProperty(object, "resourceVersion", isString) + && hasOptionalTypedProperty(object, "selfLink", isString) + ); + } + + static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiObjectMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "uid", isString) + && hasTypedProperty(object, "name", isString) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalTypedProperty(object, "selfLink", isString) + && hasOptionalTypedProperty(object, "namespace", isString) + && hasOptionalTypedProperty(object, "creationTimestamp", isString) + && hasOptionalTypedProperty(object, "continue", isString) + && hasOptionalTypedProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) + && hasOptionalTypedProperty(object, "labels", bindPredicate(isRecord, isString, isString)) + && hasOptionalTypedProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) + ); + } + + static isPartialJsonApiMetadata(object: unknown): object is Partial { + return ( + isObject(object) + && hasOptionalTypedProperty(object, "uid", isString) + && hasOptionalTypedProperty(object, "name", isString) + && hasOptionalTypedProperty(object, "resourceVersion", isString) + && hasOptionalTypedProperty(object, "selfLink", isString) + && hasOptionalTypedProperty(object, "namespace", isString) + && hasOptionalTypedProperty(object, "creationTimestamp", isString) + && hasOptionalTypedProperty(object, "continue", isString) + && hasOptionalTypedProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) + && hasOptionalTypedProperty(object, "labels", bindPredicate(isRecord, isString, isString)) + && hasOptionalTypedProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) + ); + } + + static isPartialJsonApiData(object: unknown): object is Partial { + return ( + isObject(object) + && hasOptionalTypedProperty(object, "kind", isString) + && hasOptionalTypedProperty(object, "apiVersion", isString) + && hasOptionalTypedProperty(object, "metadata", KubeObject.isPartialJsonApiMetadata) + ); + } + + static isJsonApiDataList(object: unknown, verifyItem: (val: unknown) => val is T): object is KubeJsonApiDataList { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata) + && hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem)) + ); + } + + static stringifyLabels(labels?: Partial>): string[] { + if (!labels) return []; + + return Object.entries(labels).map(([name, value]) => `${name}=${value}`); + } + + /** + * These must be RFC6902 compliant paths + */ + private static readonly nonEditablePathPrefixes = [ + "/metadata/managedFields", + "/status", + ]; + private static readonly nonEditablePaths = new Set([ + "/apiVersion", + "/kind", + "/metadata/name", + "/metadata/selfLink", + "/metadata/resourceVersion", + "/metadata/uid", + ...KubeObject.nonEditablePathPrefixes, + ]); + + constructor(data: KubeJsonApiData) { + if (typeof data !== "object") { + throw new TypeError(`Cannot create a KubeObject from ${typeof data}`); + } + + if (!data.metadata || typeof data.metadata !== "object") { + throw new KubeCreationError(`Cannot create a KubeObject from an object without metadata`, data); + } + + Object.assign(this, data); + autoBind(this); + } + + get selfLink() { + return this.metadata.selfLink; + } + + getId() { + return this.metadata.uid; + } + + getResourceVersion() { + return this.metadata.resourceVersion; + } + + getScopedName() { + const ns = this.getNs(); + const res = ns ? `${ns}/` : ""; + + return res + this.getName(); + } + + getName() { + return this.metadata.name; + } + + getNs(): Metadata["namespace"] { + // avoid "null" serialization via JSON.stringify when post data + return (this.metadata.namespace || undefined) as never; + } + + /** + * This function computes the number of milliseconds from the UNIX EPOCH to the + * creation timestamp of this object. + */ + getCreationTimestamp() { + if (!this.metadata.creationTimestamp) { + return Date.now(); + } + + return new Date(this.metadata.creationTimestamp).getTime(); + } + + /** + * @deprecated This function computes a new "now" on every call which might cause subtle issues if called multiple times + * + * NOTE: Generally you can use `getCreationTimestamp` instead. + */ + getTimeDiffFromNow(): number { + if (!this.metadata.creationTimestamp) { + return 0; + } + + return Date.now() - new Date(this.metadata.creationTimestamp).getTime(); + } + + /** + * @deprecated This function computes a new "now" on every call might cause subtle issues if called multiple times + * + * NOTE: this function also is not reactive to updates in the current time so it should not be used for renderering + */ + getAge(humanize = true, compact = true, fromNow = false): string | number { + if (fromNow) { + return moment(this.metadata.creationTimestamp).fromNow(); // "string", getTimeDiffFromNow() cannot be used + } + const diff = this.getTimeDiffFromNow(); + + if (humanize) { + return formatDuration(diff, compact); + } + + return diff; + } + + getFinalizers(): string[] { + return this.metadata.finalizers || []; + } + + getLabels(): string[] { + return KubeObject.stringifyLabels(this.metadata.labels); + } + + getAnnotations(filter = false): string[] { + const labels = KubeObject.stringifyLabels(this.metadata.annotations); + + if (!filter) { + return labels; + } + + return labels.filter(filterOutResourceApplierAnnotations); + } + + getOwnerRefs() { + const refs = this.metadata.ownerReferences || []; + const namespace = this.getNs(); + + return refs.map(ownerRef => ({ ...ownerRef, namespace })); + } + + getSearchFields() { + const { getName, getId, getNs, getAnnotations, getLabels } = this; + + return [ + getName(), + getNs(), + getId(), + ...getLabels(), + ...getAnnotations(true), + ]; + } + + toPlainObject() { + return json.parse(JSON.stringify(this)) as JsonObject; + } + + /** + * @deprecated use KubeApi.patch instead + */ + async patch(patch: Patch): Promise { + for (const op of patch) { + if (KubeObject.nonEditablePaths.has(op.path)) { + throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`); + } + + for (const pathPrefix of KubeObject.nonEditablePathPrefixes) { + if (op.path.startsWith(`${pathPrefix}/`)) { + throw new Error(`Failed to update ${this.kind}: Child JSON pointer of ${op.path} has been modified`); + } + } + } + + const di = getLegacyGlobalDiForExtensionApi(); + const requestKubeObjectPatch = di.inject(requestKubeObjectPatchInjectable); + const result = await requestKubeObjectPatch(this.getName(), this.kind, this.getNs(), patch); + + if (!result.callWasSuccessful) { + throw new Error(result.error); + } + + return result.response; + } + + /** + * Perform a full update (or more specifically a replace) + * + * Note: this is brittle if `data` is not actually partial (but instead whole). + * As fields such as `resourceVersion` will probably out of date. This is a + * common race condition. + * + * @deprecated use KubeApi.update instead + */ + async update(data: Partial): Promise { + const di = getLegacyGlobalDiForExtensionApi(); + const requestKubeObjectCreation = di.inject(requestKubeObjectCreationInjectable); + const descriptor = dump({ + ...this.toPlainObject(), + ...data, + }); + + const result = await requestKubeObjectCreation(descriptor); + + if (!result.callWasSuccessful) { + throw new Error(result.error); + } + + return result.response; + } + + /** + * @deprecated use KubeApi.delete instead + */ + delete(params?: object) { + assert(this.selfLink, "selfLink must be present to delete self"); + + const di = getLegacyGlobalDiForExtensionApi(); + const apiKube = di.inject(apiKubeInjectionToken); + + return apiKube.del(this.selfLink, params); + } +} diff --git a/packages/core/src/common/k8s-api/kube-watch-event.ts b/packages/core/src/common/k8s-api/kube-watch-event.ts new file mode 100644 index 0000000000..0f42bf4054 --- /dev/null +++ b/packages/core/src/common/k8s-api/kube-watch-event.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeStatusData } from "./kube-object"; + +export type IKubeWatchEvent = { + readonly type: "ADDED" | "MODIFIED" | "DELETED"; + readonly object: T; +} | { + readonly type: "ERROR"; + readonly object?: KubeStatusData; +}; + diff --git a/packages/core/src/common/k8s-api/maybe-kube-api.injectable.ts b/packages/core/src/common/k8s-api/maybe-kube-api.injectable.ts new file mode 100644 index 0000000000..6717c00881 --- /dev/null +++ b/packages/core/src/common/k8s-api/maybe-kube-api.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { apiKubeInjectionToken } from "./api-kube"; + +const maybeKubeApiInjectable = getInjectable({ + id: "maybe-kube-api", + instantiate: (di) => { + try { + return di.inject(apiKubeInjectionToken); + } catch { + return undefined; + } + }, +}); + +export default maybeKubeApiInjectable; diff --git a/packages/core/src/common/k8s-api/selected-filter-namespaces.injectable.ts b/packages/core/src/common/k8s-api/selected-filter-namespaces.injectable.ts new file mode 100644 index 0000000000..6c70a665a4 --- /dev/null +++ b/packages/core/src/common/k8s-api/selected-filter-namespaces.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import namespaceStoreInjectable from "../../renderer/components/+namespaces/store.injectable"; +import { storesAndApisCanBeCreatedInjectionToken } from "./stores-apis-can-be-created.token"; + +const selectedFilterNamespacesInjectable = getInjectable({ + id: "selected-filter-namespaces", + instantiate: (di) => { + if (!di.inject(storesAndApisCanBeCreatedInjectionToken)) { + // Dummy value so that this works in all environments + return computed(() => []); + } + + const store = di.inject(namespaceStoreInjectable); + + return computed(() => [...store.contextNamespaces]); + }, +}); + +export default selectedFilterNamespacesInjectable; diff --git a/packages/core/src/common/k8s-api/stores-apis-can-be-created.token.ts b/packages/core/src/common/k8s-api/stores-apis-can-be-created.token.ts new file mode 100644 index 0000000000..746f822c0c --- /dev/null +++ b/packages/core/src/common/k8s-api/stores-apis-can-be-created.token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const storesAndApisCanBeCreatedInjectionToken = getInjectionToken({ + id: "stores-and-apis-can-be-created-token", +}); diff --git a/packages/core/src/common/k8s-api/window-location.global-override-for-injectable.ts b/packages/core/src/common/k8s-api/window-location.global-override-for-injectable.ts new file mode 100644 index 0000000000..616e110c88 --- /dev/null +++ b/packages/core/src/common/k8s-api/window-location.global-override-for-injectable.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import windowLocationInjectable from "./window-location.injectable"; + +export default getGlobalOverride(windowLocationInjectable, () => ({ + host: "localhost", + port: "12345", +})); diff --git a/packages/core/src/common/k8s-api/window-location.injectable.ts b/packages/core/src/common/k8s-api/window-location.injectable.ts new file mode 100644 index 0000000000..80bcd44be6 --- /dev/null +++ b/packages/core/src/common/k8s-api/window-location.injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const windowLocationInjectable = getInjectable({ + id: "window-location", + instantiate: () => { + const { host, port } = window.location; + + return { host, port }; + }, + causesSideEffects: true, +}); + +export default windowLocationInjectable; diff --git a/packages/core/src/common/k8s/create-resource-stack.injectable.ts b/packages/core/src/common/k8s/create-resource-stack.injectable.ts new file mode 100644 index 0000000000..083d240b3a --- /dev/null +++ b/packages/core/src/common/k8s/create-resource-stack.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { KubernetesCluster } from "../catalog-entities"; +import readDirectoryInjectable from "../fs/read-directory.injectable"; +import readFileInjectable from "../fs/read-file.injectable"; +import { kubectlApplyAllInjectionToken, kubectlDeleteAllInjectionToken } from "../kube-helpers/channels"; +import loggerInjectable from "../logger.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; +import type { ResourceApplyingStack, ResourceStackDependencies } from "./resource-stack"; +import { ResourceStack } from "./resource-stack"; + +export type CreateResourceStack = (cluster: KubernetesCluster, name: string) => ResourceApplyingStack; + +const createResourceStackInjectable = getInjectable({ + id: "create-resource-stack", + instantiate: (di): CreateResourceStack => { + const deps: ResourceStackDependencies = { + joinPaths: di.inject(joinPathsInjectable), + kubectlApplyAll: di.inject(kubectlApplyAllInjectionToken), + kubectlDeleteAll: di.inject(kubectlDeleteAllInjectionToken), + logger: di.inject(loggerInjectable), + readDirectory: di.inject(readDirectoryInjectable), + readFile: di.inject(readFileInjectable), + }; + + return (cluster, name) => new ResourceStack(deps, cluster, name); + }, +}); + +export default createResourceStackInjectable; diff --git a/packages/core/src/common/k8s/resource-stack.ts b/packages/core/src/common/k8s/resource-stack.ts new file mode 100644 index 0000000000..771b48b413 --- /dev/null +++ b/packages/core/src/common/k8s/resource-stack.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import hb from "handlebars"; +import type { KubernetesCluster } from "../catalog-entities"; +import yaml from "js-yaml"; +import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import productNameInjectable from "../vars/product-name.injectable"; +import type { AsyncResult } from "../utils/async-result"; +import type { Logger } from "../logger"; +import type { KubectlApplyAll, KubectlDeleteAll } from "../kube-helpers/channels"; +import type { ReadDirectory } from "../fs/read-directory.injectable"; +import type { JoinPaths } from "../path/join-paths.injectable"; +import type { ReadFile } from "../fs/read-file.injectable"; +import { hasTypedProperty, isObject } from "../utils"; + +export interface ResourceApplyingStack { + kubectlApplyFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise; + kubectlDeleteFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise; +} + +export interface ResourceStackDependencies { + readonly logger: Logger; + kubectlApplyAll: KubectlApplyAll; + kubectlDeleteAll: KubectlDeleteAll; + readDirectory: ReadDirectory; + joinPaths: JoinPaths; + readFile: ReadFile; +} + +export class ResourceStack { + constructor( + protected readonly dependencies: ResourceStackDependencies, + protected readonly cluster: KubernetesCluster, + protected readonly name: string, + ) {} + + /** + * + * @param folderPath folder path that is searched for files defining kubernetes resources. + * @param templateContext sets the template parameters that are to be applied to any templated kubernetes resources that are to be applied. + */ + async kubectlApplyFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise { + const resources = await this.renderTemplates(folderPath, templateContext); + const result = await this.applyResources(resources, extraArgs); + + if (result.callWasSuccessful) { + return result.response; + } + + this.dependencies.logger.warn(`[RESOURCE-STACK]: failed to apply resources: ${result.error}`); + + return ""; + } + + /** + * + * @param folderPath folder path that is searched for files defining kubernetes resources. + * @param templateContext sets the template parameters that are to be applied to any templated kubernetes resources that are to be applied. + */ + async kubectlDeleteFolder(folderPath: string, templateContext?: any, extraArgs?: string[]): Promise { + const resources = await this.renderTemplates(folderPath, templateContext); + const result = await this.deleteResources(resources, extraArgs); + + if (result.callWasSuccessful) { + return result.response; + } + + this.dependencies.logger.warn(`[RESOURCE-STACK]: failed to delete resources: ${result.error}`); + + return ""; + } + + protected async applyResources(resources: string[], extraArgs: string[] = []): Promise> { + const kubectlArgs = [...extraArgs, ...this.getAdditionalArgs(extraArgs)]; + + return this.dependencies.kubectlApplyAll({ + clusterId: this.cluster.getId(), + resources, + extraArgs: kubectlArgs, + }); + } + + protected async deleteResources(resources: string[], extraArgs: string[] = []): Promise> { + const kubectlArgs = [...extraArgs, ...this.getAdditionalArgs(extraArgs)]; + + return this.dependencies.kubectlDeleteAll({ + clusterId: this.cluster.getId(), + resources, + extraArgs: kubectlArgs, + }); + } + + protected getAdditionalArgs(kubectlArgs: string[]): string[] { + if (!kubectlArgs.includes("-l") && !kubectlArgs.includes("--label")) { + return ["-l", `app.kubernetes.io/name=${this.name}`]; + } + + return []; + } + + protected async renderTemplates(folderPath: string, templateContext: any): Promise { + const resources: string[] = []; + const di = getLegacyGlobalDiForExtensionApi(); + const productName = di.inject(productNameInjectable); + + this.dependencies.logger.info(`[RESOURCE-STACK]: render templates from ${folderPath}`); + const files = await this.dependencies.readDirectory(folderPath); + + for (const filename of files) { + const file = this.dependencies.joinPaths(folderPath, filename); + const raw = await this.dependencies.readFile(file); + const data = ( + filename.endsWith(".hb") + ? hb.compile(raw)(templateContext) + : raw + ).trim(); + + if (!data) { + continue; + } + + for (const entry of yaml.loadAll(data)) { + if (typeof entry !== "object" || !entry) { + continue; + } + + if (hasTypedProperty(entry, "metadata", isObject)) { + const labels = (entry.metadata.labels ??= {}) as Partial>; + + labels["app.kubernetes.io/name"] = this.name; + labels["app.kubernetes.io/managed-by"] = productName; + labels["app.kubernetes.io/created-by"] = "resource-stack"; + } + + resources.push(yaml.dump(entry)); + } + } + + return resources; + } +} diff --git a/packages/core/src/common/kube-helpers.ts b/packages/core/src/common/kube-helpers.ts new file mode 100644 index 0000000000..c439c29d16 --- /dev/null +++ b/packages/core/src/common/kube-helpers.ts @@ -0,0 +1,266 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeConfig } from "@kubernetes/client-node"; +import yaml from "js-yaml"; +import type { Cluster, Context, User } from "@kubernetes/client-node/dist/config_types"; +import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types"; +import { isDefined } from "./utils"; +import Joi from "joi"; +import type { PartialDeep } from "type-fest"; + +const clusterSchema = Joi.object({ + name: Joi + .string() + .min(1) + .required(), + cluster: Joi + .object({ + server: Joi + .string() + .min(1) + .required(), + }) + .required(), +}); + +const userSchema = Joi.object({ + name: Joi.string() + .min(1) + .required(), +}); + +const contextSchema = Joi.object({ + name: Joi.string() + .min(1) + .required(), + context: Joi.object({ + cluster: Joi.string() + .min(1) + .required(), + user: Joi.string() + .min(1) + .required(), + }), +}); + +const kubeConfigSchema = Joi.object({ + users: Joi + .array() + .items(userSchema) + .optional(), + clusters: Joi + .array() + .items(clusterSchema) + .optional(), + contexts: Joi + .array() + .items(contextSchema) + .optional(), + "current-context": Joi + .string() + .min(1) + .optional(), +}) + .required(); + +interface KubeConfigOptions { + clusters: Cluster[]; + users: User[]; + contexts: Context[]; + currentContext?: string; +} + +interface OptionsResult { + options: KubeConfigOptions; + error: Joi.ValidationError | undefined; +} + +function loadToOptions(rawYaml: string): OptionsResult { + const parsed = yaml.load(rawYaml); + const { error } = kubeConfigSchema.validate(parsed, { + abortEarly: false, + allowUnknown: true, + }); + const { value } = kubeConfigSchema.validate(parsed, { + abortEarly: false, + allowUnknown: true, + stripUnknown: { + arrays: true, + }, + }); + const { + clusters: rawClusters, + users: rawUsers, + contexts: rawContexts, + "current-context": currentContext, + } = value ?? {}; + const clusters = newClusters(rawClusters); + const users = newUsers(rawUsers); + const contexts = newContexts(rawContexts); + + return { + options: { clusters, users, contexts, currentContext }, + error, + }; +} + +export function loadFromOptions(options: KubeConfigOptions): KubeConfig { + const kc = new KubeConfig(); + + // need to load using the kubernetes client to generate a kubeconfig object + kc.loadFromOptions(options); + + return kc; +} + +export interface ConfigResult { + config: KubeConfig; + error: Joi.ValidationError | undefined; +} + +export function loadConfigFromString(content: string): ConfigResult { + const { options, error } = loadToOptions(content); + + return { + config: loadFromOptions(options), + error, + }; +} + +export interface SplitConfigEntry { + config: KubeConfig; + validationResult: ValidateKubeConfigResult; +} + +/** + * Breaks kube config into several configs. Each context as it own KubeConfig object + */ +export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] { + return kubeConfig.getContexts().map(ctx => { + const config = new KubeConfig(); + const cluster = kubeConfig.getCluster(ctx.cluster); + const user = kubeConfig.getUser(ctx.user); + const context = kubeConfig.getContextObject(ctx.name); + + if (cluster) { + config.addCluster(cluster); + } + + if (user) { + config.addUser(user); + } + + if (context) { + config.addContext(context); + } + + config.setCurrentContext(ctx.name); + + return { + config, + validationResult: validateKubeConfig(config, ctx.name), + }; + }); +} + +/** + * Pretty format the object as human readable yaml, such as would be on the filesystem + * @param kubeConfig The kubeconfig object to format as pretty yaml + * @returns The yaml representation of the kubeconfig object + */ +export function dumpConfigYaml(kubeConfig: PartialDeep): string { + const clusters = kubeConfig.clusters + ?.filter(isDefined) + .map(cluster => ({ + name: cluster.name, + cluster: { + "certificate-authority-data": cluster.caData, + "certificate-authority": cluster.caFile, + server: cluster.server, + "insecure-skip-tls-verify": cluster.skipTLSVerify, + }, + })); + const contexts = kubeConfig.contexts + ?.filter(isDefined) + .map(context => ({ + name: context.name, + context: { + cluster: context.cluster, + user: context.user, + namespace: context.namespace, + }, + })); + const users = kubeConfig.users + ?.filter(isDefined) + .map(user => ({ + name: user.name, + user: { + "client-certificate-data": user.certData, + "client-certificate": user.certFile, + "client-key-data": user.keyData, + "client-key": user.keyFile, + "auth-provider": user.authProvider, + exec: user.exec, + token: user.token, + username: user.username, + password: user.password, + }, + })); + const config = { + apiVersion: "v1", + kind: "Config", + preferences: {}, + "current-context": kubeConfig.currentContext, + clusters, + contexts, + users, + }; + + // skipInvalid: true makes dump ignore undefined values + return yaml.dump(config, { skipInvalid: true }); +} + +export type ValidateKubeConfigResult = { + error: Error; +} | { + error?: undefined; + context: Context; + cluster: Cluster; + user: User; +}; + +/** + * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required) + * + * Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes + */ +export function validateKubeConfig(config: KubeConfig, contextName: string): ValidateKubeConfigResult { + const context = config.getContextObject(contextName); + + if (!context) { + return { + error: new Error(`No valid context object provided in kubeconfig for context '${contextName}'`), + }; + } + + const cluster = config.getCluster(context.cluster); + + if (!cluster) { + return { + error: new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`), + }; + } + + const user = config.getUser(context.user); + + if (!user) { + return { + error: new Error(`No valid user object provided in kubeconfig for context '${contextName}'`), + }; + } + + return { cluster, user, context }; +} diff --git a/packages/core/src/common/kube-helpers/channels.ts b/packages/core/src/common/kube-helpers/channels.ts new file mode 100644 index 0000000000..4782f64367 --- /dev/null +++ b/packages/core/src/common/kube-helpers/channels.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Asyncify } from "type-fest"; +import type { RequestChannelHandler } from "../../main/utils/channel/channel-listeners/listener-tokens"; +import type { ClusterId } from "../cluster-types"; +import type { AsyncResult } from "../utils/async-result"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export interface KubectlApplyAllArgs { + clusterId: ClusterId; + resources: string[]; + extraArgs: string[]; +} + +export const kubectlApplyAllChannel: RequestChannel> = { + id: "kubectl-apply-all", +}; + +export type KubectlApplyAll = Asyncify>; + +export const kubectlApplyAllInjectionToken = getInjectionToken({ + id: "kubectl-apply-all", +}); + +export interface KubectlDeleteAllArgs { + clusterId: ClusterId; + resources: string[]; + extraArgs: string[]; +} + +export const kubectlDeleteAllChannel: RequestChannel> = { + id: "kubectl-delete-all", +}; + +export type KubectlDeleteAll = Asyncify>; + +export const kubectlDeleteAllInjectionToken = getInjectionToken({ + id: "kubectl-delete-all", +}); diff --git a/packages/core/src/common/kube-helpers/load-config-from-file.injectable.ts b/packages/core/src/common/kube-helpers/load-config-from-file.injectable.ts new file mode 100644 index 0000000000..afa9d3c070 --- /dev/null +++ b/packages/core/src/common/kube-helpers/load-config-from-file.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import readFileInjectable from "../fs/read-file.injectable"; +import type { ConfigResult } from "../kube-helpers"; +import { loadConfigFromString } from "../kube-helpers"; +import resolveTildeInjectable from "../path/resolve-tilde.injectable"; + +export type LoadConfigfromFile = (filePath: string) => Promise; + +const loadConfigfromFileInjectable = getInjectable({ + id: "load-configfrom-file", + instantiate: (di): LoadConfigfromFile => { + const readFile = di.inject(readFileInjectable); + const resolveTilde = di.inject(resolveTildeInjectable); + + return async (filePath) => loadConfigFromString(await readFile(resolveTilde(filePath))); + }, +}); + +export default loadConfigfromFileInjectable; diff --git a/packages/core/src/common/library copy.ts b/packages/core/src/common/library copy.ts new file mode 100644 index 0000000000..765f70edcb --- /dev/null +++ b/packages/core/src/common/library copy.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import applicationInformationToken from "./vars/application-information-token"; +import type { ApplicationInformation } from "./vars/application-information-token"; +import { bundledExtensionInjectionToken } from "../extensions/extension-discovery/bundled-extension-token"; +import * as extensionApi from "../extensions/common-api"; + +// @experimental +export { + applicationInformationToken, + ApplicationInformation, + bundledExtensionInjectionToken, + extensionApi, +}; diff --git a/packages/core/src/common/library.ts b/packages/core/src/common/library.ts new file mode 100644 index 0000000000..809f4ff666 --- /dev/null +++ b/packages/core/src/common/library.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import applicationInformationToken from "./vars/application-information-token"; +import type { ApplicationInformation } from "./vars/application-information-token"; +import { bundledExtensionInjectionToken } from "../extensions/extension-discovery/bundled-extension-token"; +import * as extensionApi from "../extensions/common-api"; + +// @experimental +export { + applicationInformationToken, + ApplicationInformation, + bundledExtensionInjectionToken, + extensionApi +}; diff --git a/packages/core/src/common/log-error.global-override-for-injectable.ts b/packages/core/src/common/log-error.global-override-for-injectable.ts new file mode 100644 index 0000000000..e3a03c2802 --- /dev/null +++ b/packages/core/src/common/log-error.global-override-for-injectable.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getGlobalOverrideForFunction } from "./test-utils/get-global-override-for-function"; +import logErrorInjectable from "./log-error.injectable"; + +// Note: this should remain as it is, and throw if called. Logging error is something +// that cannot happen without a unit test explicitly causing it. It cannot be allowed +// to happen without author of unit test knowing it. +export default getGlobalOverrideForFunction(logErrorInjectable); diff --git a/packages/core/src/common/log-error.injectable.ts b/packages/core/src/common/log-error.injectable.ts new file mode 100644 index 0000000000..4fab2cd546 --- /dev/null +++ b/packages/core/src/common/log-error.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "./logger.injectable"; + +const logErrorInjectable = getInjectable({ + id: "log-error", + instantiate: (di) => di.inject(loggerInjectable).error, +}); + +export default logErrorInjectable; diff --git a/packages/core/src/common/logger.global-override-for-injectable.ts b/packages/core/src/common/logger.global-override-for-injectable.ts new file mode 100644 index 0000000000..cad548cd22 --- /dev/null +++ b/packages/core/src/common/logger.global-override-for-injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import loggerInjectable from "./logger.injectable"; +import { getGlobalOverride } from "./test-utils/get-global-override"; +import { noop } from "./utils"; + +export default getGlobalOverride(loggerInjectable, () => ({ + warn: noop, + debug: noop, + error: noop, + info: noop, + silly: noop, +})); diff --git a/packages/core/src/common/logger.injectable.ts b/packages/core/src/common/logger.injectable.ts new file mode 100644 index 0000000000..8e9dd2a6a7 --- /dev/null +++ b/packages/core/src/common/logger.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { createLogger, format } from "winston"; +import type { Logger } from "./logger"; +import { loggerTransportInjectionToken } from "./logger/transports"; + +const loggerInjectable = getInjectable({ + id: "logger", + instantiate: (di): Logger => { + const baseLogger = createLogger({ + format: format.combine( + format.splat(), + format.simple(), + ), + transports: di.injectMany(loggerTransportInjectionToken), + }); + + return { + debug: (message, ...data) => baseLogger.debug(message, ...data), + info: (message, ...data) => baseLogger.info(message, ...data), + warn: (message, ...data) => baseLogger.warn(message, ...data), + error: (message, ...data) => baseLogger.error(message, ...data), + silly: (message, ...data) => baseLogger.silly(message, ...data), + }; + }, +}); + +export default loggerInjectable; diff --git a/packages/core/src/common/logger.ts b/packages/core/src/common/logger.ts new file mode 100644 index 0000000000..ad81271bfa --- /dev/null +++ b/packages/core/src/common/logger.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +export interface Logger { + info: (message: string, ...args: any) => void; + error: (message: string, ...args: any) => void; + debug: (message: string, ...args: any) => void; + warn: (message: string, ...args: any) => void; + silly: (message: string, ...args: any) => void; +} diff --git a/packages/core/src/common/logger/prefixed-logger.injectable.ts b/packages/core/src/common/logger/prefixed-logger.injectable.ts new file mode 100644 index 0000000000..36f86c532f --- /dev/null +++ b/packages/core/src/common/logger/prefixed-logger.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Logger } from "../logger"; +import loggerInjectable from "../logger.injectable"; + +const prefixedLoggerInjectable = getInjectable({ + id: "prefixed-logger", + instantiate: (di, prefix): Logger => { + const logger = di.inject(loggerInjectable); + + return { + debug: (message, ...args) => { + logger.debug(`[${prefix}]: ${message}`, ...args); + }, + error: (message, ...args) => { + logger.error(`[${prefix}]: ${message}`, ...args); + }, + info: (message, ...args) => { + logger.info(`[${prefix}]: ${message}`, ...args); + }, + silly: (message, ...args) => { + logger.silly(`[${prefix}]: ${message}`, ...args); + }, + warn: (message, ...args) => { + logger.warn(`[${prefix}]: ${message}`, ...args); + }, + }; + }, + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, prefix: string) => prefix, + }), +}); + +export default prefixedLoggerInjectable; diff --git a/packages/core/src/common/logger/transports.ts b/packages/core/src/common/logger/transports.ts new file mode 100644 index 0000000000..1407eb91b8 --- /dev/null +++ b/packages/core/src/common/logger/transports.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type TransportStream from "winston-transport"; + +export const loggerTransportInjectionToken = getInjectionToken({ + id: "logger-transport", +}); diff --git a/packages/core/src/common/os/home-directory-path.injectable.ts b/packages/core/src/common/os/home-directory-path.injectable.ts new file mode 100644 index 0000000000..83b4b0cdff --- /dev/null +++ b/packages/core/src/common/os/home-directory-path.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import userInfoInjectable from "../user-store/user-info.injectable"; + +const homeDirectoryPathInjectable = getInjectable({ + id: "home-directory-path", + instantiate: (di) => di.inject(userInfoInjectable).homedir, +}); + +export default homeDirectoryPathInjectable; diff --git a/packages/core/src/common/os/temp-directory-path.global-override-for-injectable.ts b/packages/core/src/common/os/temp-directory-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..05615644f9 --- /dev/null +++ b/packages/core/src/common/os/temp-directory-path.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import tempDirectoryPathInjectable from "./temp-directory-path.injectable"; + +export default getGlobalOverride(tempDirectoryPathInjectable, () => "/some-temp-directory"); diff --git a/packages/core/src/common/os/temp-directory-path.injectable.ts b/packages/core/src/common/os/temp-directory-path.injectable.ts new file mode 100644 index 0000000000..46fc5db67d --- /dev/null +++ b/packages/core/src/common/os/temp-directory-path.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { tmpdir } from "os"; + +const tempDirectoryPathInjectable = getInjectable({ + id: "temp-directory-path", + instantiate: () => tmpdir(), + causesSideEffects: true, +}); + +export default tempDirectoryPathInjectable; diff --git a/packages/core/src/common/path/get-absolute-path.global-override-for-injectable.ts b/packages/core/src/common/path/get-absolute-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..15f377cb2c --- /dev/null +++ b/packages/core/src/common/path/get-absolute-path.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getAbsolutePathInjectable from "./get-absolute-path.injectable"; + +export default getGlobalOverride(getAbsolutePathInjectable, () => path.posix.resolve); diff --git a/packages/core/src/common/path/get-absolute-path.injectable.ts b/packages/core/src/common/path/get-absolute-path.injectable.ts new file mode 100644 index 0000000000..8919605942 --- /dev/null +++ b/packages/core/src/common/path/get-absolute-path.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +export type GetAbsolutePath = (...args: string[]) => string; + +const getAbsolutePathInjectable = getInjectable({ + id: "get-absolute-path", + + instantiate: (): GetAbsolutePath => path.resolve, + + // This causes side effect e.g. Windows creates different kinds of + // absolute paths than linux + causesSideEffects: true, +}); + +export default getAbsolutePathInjectable; diff --git a/packages/core/src/common/path/get-basename.global-override-for-injectable.ts b/packages/core/src/common/path/get-basename.global-override-for-injectable.ts new file mode 100644 index 0000000000..913ec9c5c2 --- /dev/null +++ b/packages/core/src/common/path/get-basename.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getBasenameOfPathInjectable from "./get-basename.injectable"; + +export default getGlobalOverride(getBasenameOfPathInjectable, () => path.posix.basename); diff --git a/packages/core/src/common/path/get-basename.injectable.ts b/packages/core/src/common/path/get-basename.injectable.ts new file mode 100644 index 0000000000..be92bde7f5 --- /dev/null +++ b/packages/core/src/common/path/get-basename.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +export type GetBasenameOfPath = (path: string) => string; + +const getBasenameOfPathInjectable = getInjectable({ + id: "get-basename-of-path", + instantiate: (): GetBasenameOfPath => path.basename, + causesSideEffects: true, +}); + +export default getBasenameOfPathInjectable; diff --git a/packages/core/src/common/path/get-dirname.global-override-for-injectable.ts b/packages/core/src/common/path/get-dirname.global-override-for-injectable.ts new file mode 100644 index 0000000000..ed694de182 --- /dev/null +++ b/packages/core/src/common/path/get-dirname.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getDirnameOfPathInjectable from "./get-dirname.injectable"; + +export default getGlobalOverride(getDirnameOfPathInjectable, () => path.posix.dirname); diff --git a/packages/core/src/common/path/get-dirname.injectable.ts b/packages/core/src/common/path/get-dirname.injectable.ts new file mode 100644 index 0000000000..93b4496767 --- /dev/null +++ b/packages/core/src/common/path/get-dirname.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +export type GetDirnameOfPath = (path: string) => string; + +const getDirnameOfPathInjectable = getInjectable({ + id: "get-dirname-of-path", + instantiate: (): GetDirnameOfPath => path.dirname, + causesSideEffects: true, +}); + +export default getDirnameOfPathInjectable; diff --git a/packages/core/src/common/path/get-relative-path.global-override-for-injectable.ts b/packages/core/src/common/path/get-relative-path.global-override-for-injectable.ts new file mode 100644 index 0000000000..9e96b70301 --- /dev/null +++ b/packages/core/src/common/path/get-relative-path.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getRelativePathInjectable from "./get-relative-path.injectable"; + +export default getGlobalOverride(getRelativePathInjectable, () => path.posix.relative); diff --git a/packages/core/src/common/path/get-relative-path.injectable.ts b/packages/core/src/common/path/get-relative-path.injectable.ts new file mode 100644 index 0000000000..18b5d832de --- /dev/null +++ b/packages/core/src/common/path/get-relative-path.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +export type GetRelativePath = (from: string, to: string) => string; + +const getRelativePathInjectable = getInjectable({ + id: "get-relative-path", + instantiate: (): GetRelativePath => path.relative, + causesSideEffects: true, +}); + +export default getRelativePathInjectable; diff --git a/packages/core/src/common/path/is-logical-child-path.injectable.ts b/packages/core/src/common/path/is-logical-child-path.injectable.ts new file mode 100644 index 0000000000..1a52b66c0f --- /dev/null +++ b/packages/core/src/common/path/is-logical-child-path.injectable.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getAbsolutePathInjectable from "./get-absolute-path.injectable"; +import getDirnameOfPathInjectable from "./get-dirname.injectable"; + +/** + * Checks if `testPath` represents a potential filesystem entry that would be + * logically "within" the `parentPath` directory. + * + * This function will return `true` in the above case, and `false` otherwise. + * It will return `false` if the two paths are the same (after resolving them). + * + * The function makes no FS calls and is platform dependant. Meaning that the + * results are only guaranteed to be correct for the platform you are running + * on. + * @param parentPath The known path of a directory + * @param testPath The path that is to be tested + */ +export type IsLogicalChildPath = (parentPath: string, testPath: string) => boolean; + +const isLogicalChildPathInjectable = getInjectable({ + id: "is-logical-child-path", + instantiate: (di): IsLogicalChildPath => { + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return (parentPath, testPath) => { + const resolvedParentPath = getAbsolutePath(parentPath); + let resolvedTestPath = getAbsolutePath(testPath); + + if (resolvedParentPath === resolvedTestPath) { + return false; + } + + while (resolvedTestPath.length >= resolvedParentPath.length) { + if (resolvedTestPath === resolvedParentPath) { + return true; + } + + resolvedTestPath = getDirnameOfPath(resolvedTestPath); + } + + return false; + }; + }, +}); + +export default isLogicalChildPathInjectable; diff --git a/packages/core/src/common/path/join-paths.global-override-for-injectable.ts b/packages/core/src/common/path/join-paths.global-override-for-injectable.ts new file mode 100644 index 0000000000..d3e9d5e4c2 --- /dev/null +++ b/packages/core/src/common/path/join-paths.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import joinPathsInjectable from "./join-paths.injectable"; + +export default getGlobalOverride(joinPathsInjectable, () => path.posix.join); diff --git a/packages/core/src/common/path/join-paths.injectable.ts b/packages/core/src/common/path/join-paths.injectable.ts new file mode 100644 index 0000000000..dc63b48307 --- /dev/null +++ b/packages/core/src/common/path/join-paths.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +export type JoinPaths = (...args: string[]) => string; + +const joinPathsInjectable = getInjectable({ + id: "join-paths", + instantiate: (): JoinPaths => path.join, + + // This causes side effect e.g. Windows uses different separator than e.g. linux + causesSideEffects: true, +}); + +export default joinPathsInjectable; diff --git a/packages/core/src/common/path/parse.global-override-for-injectable.ts b/packages/core/src/common/path/parse.global-override-for-injectable.ts new file mode 100644 index 0000000000..fad97db696 --- /dev/null +++ b/packages/core/src/common/path/parse.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import parsePathInjectable from "./parse.injectable"; + +export default getGlobalOverride(parsePathInjectable, () => path.posix.parse); diff --git a/packages/core/src/common/path/parse.injectable.ts b/packages/core/src/common/path/parse.injectable.ts new file mode 100644 index 0000000000..a32dfb3fa5 --- /dev/null +++ b/packages/core/src/common/path/parse.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +const parsePathInjectable = getInjectable({ + id: "parse-path", + instantiate: () => path.parse, + causesSideEffects: true, +}); + +export default parsePathInjectable; diff --git a/packages/core/src/common/path/resolve-path.injectable.ts b/packages/core/src/common/path/resolve-path.injectable.ts new file mode 100644 index 0000000000..75a1e98c59 --- /dev/null +++ b/packages/core/src/common/path/resolve-path.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import getAbsolutePathInjectable from "./get-absolute-path.injectable"; +import resolveTildeInjectable from "./resolve-tilde.injectable"; + +export type ResolvePath = (path: string) => string; + +const resolvePathInjectable = getInjectable({ + id: "resolve-path", + instantiate: (di): ResolvePath => { + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const resolveTilde = di.inject(resolveTildeInjectable); + + return (filePath) => getAbsolutePath(resolveTilde(filePath)); + }, +}); + +export default resolvePathInjectable; diff --git a/packages/core/src/common/path/resolve-tilde.injectable.ts b/packages/core/src/common/path/resolve-tilde.injectable.ts new file mode 100644 index 0000000000..86d267aa4f --- /dev/null +++ b/packages/core/src/common/path/resolve-tilde.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import homeDirectoryPathInjectable from "../os/home-directory-path.injectable"; +import fileSystemSeparatorInjectable from "./separator.injectable"; + +export type ResolveTilde = (path: string) => string; + +const resolveTildeInjectable = getInjectable({ + id: "resolve-tilde", + instantiate: (di): ResolveTilde => { + const homeDirectoryPath = di.inject(homeDirectoryPathInjectable); + const fileSystemSeparator = di.inject(fileSystemSeparatorInjectable); + + return (filePath) => { + if (filePath === "~") { + return homeDirectoryPath; + } + + if (filePath === `~${fileSystemSeparator}`) { + return `${homeDirectoryPath}${filePath.slice(1)}`; + } + + return filePath; + }; + }, +}); + +export default resolveTildeInjectable; diff --git a/packages/core/src/common/path/separator.global-override-for-injectable.ts b/packages/core/src/common/path/separator.global-override-for-injectable.ts new file mode 100644 index 0000000000..655f8908b0 --- /dev/null +++ b/packages/core/src/common/path/separator.global-override-for-injectable.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { getGlobalOverride } from "../test-utils/get-global-override"; +import fileSystemSeparatorInjectable from "./separator.injectable"; + +export default getGlobalOverride(fileSystemSeparatorInjectable, () => path.posix.sep); diff --git a/packages/core/src/common/path/separator.injectable.ts b/packages/core/src/common/path/separator.injectable.ts new file mode 100644 index 0000000000..5b0413b56f --- /dev/null +++ b/packages/core/src/common/path/separator.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +const fileSystemSeparatorInjectable = getInjectable({ + id: "file-system-separator", + instantiate: () => path.sep, + causesSideEffects: true, +}); + +export default fileSystemSeparatorInjectable; diff --git a/packages/core/src/common/protocol-handler/error.ts b/packages/core/src/common/protocol-handler/error.ts new file mode 100644 index 0000000000..707c8d420b --- /dev/null +++ b/packages/core/src/common/protocol-handler/error.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type Url from "url-parse"; + +export enum RoutingErrorType { + INVALID_PROTOCOL = "invalid-protocol", + INVALID_HOST = "invalid-host", + INVALID_PATHNAME = "invalid-pathname", + NO_HANDLER = "no-handler", + NO_EXTENSION_ID = "no-ext-id", + MISSING_EXTENSION = "missing-ext", +} + +export class RoutingError extends Error { + /** + * Will be set if the routing error originated in an extension route table + */ + public extensionName?: string; + + constructor(public type: RoutingErrorType, public url: Url) { + super("routing error"); + } + + toString(): string { + switch (this.type) { + case RoutingErrorType.INVALID_HOST: + return "invalid host"; + case RoutingErrorType.INVALID_PROTOCOL: + return "invalid protocol"; + case RoutingErrorType.INVALID_PATHNAME: + return "invalid pathname"; + case RoutingErrorType.NO_EXTENSION_ID: + return "no extension ID"; + case RoutingErrorType.MISSING_EXTENSION: + return "extension not found"; + default: + return `unknown error: ${this.type}`; + } + } +} diff --git a/packages/core/src/common/protocol-handler/index.ts b/packages/core/src/common/protocol-handler/index.ts new file mode 100644 index 0000000000..b329c09046 --- /dev/null +++ b/packages/core/src/common/protocol-handler/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./error"; +export * from "./router"; diff --git a/packages/core/src/common/protocol-handler/registration.ts b/packages/core/src/common/protocol-handler/registration.ts new file mode 100644 index 0000000000..9fdf390bee --- /dev/null +++ b/packages/core/src/common/protocol-handler/registration.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * ProtocolHandlerRegistration is the data required for an extension to register + * a handler to a specific path or dynamic path. + */ +export interface ProtocolHandlerRegistration { + pathSchema: string; + handler: RouteHandler; +} + +/** + * The collection of the dynamic parts of a URI which initiated a `lens://` + * protocol request + */ +export interface RouteParams { + /** + * the parts of the URI query string + */ + search: Record; + + /** + * the matching parts of the path. The dynamic parts of the URI path. + */ + pathname: Record; + + /** + * if the most specific path schema that is matched does not cover the whole + * of the URI's path. Then this field will be set to the remaining path + * segments. + * + * Example: + * + * If the path schema `/landing/:type` is the matched schema for the URI + * `/landing/soft/easy` then this field will be set to `"/easy"`. + */ + tail?: string; +} + +/** + * RouteHandler represents the function signature of the handler function for + * `lens://` protocol routing. + */ +export interface RouteHandler { + (params: RouteParams): void; +} diff --git a/packages/core/src/common/protocol-handler/router.ts b/packages/core/src/common/protocol-handler/router.ts new file mode 100644 index 0000000000..8c9915b287 --- /dev/null +++ b/packages/core/src/common/protocol-handler/router.ts @@ -0,0 +1,277 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { match } from "react-router"; +import { matchPath } from "react-router"; +import { countBy } from "lodash"; +import { isDefined, iter } from "../utils"; +import { pathToRegexp } from "path-to-regexp"; +import type Url from "url-parse"; +import { RoutingError, RoutingErrorType } from "./error"; +import type { ExtensionsStore } from "../../extensions/extensions-store/extensions-store"; +import type { ExtensionLoader } from "../../extensions/extension-loader"; +import type { LensExtension } from "../../extensions/lens-extension"; +import type { RouteHandler, RouteParams } from "./registration"; +import { when } from "mobx"; +import { ipcRenderer } from "electron"; +import type { Logger } from "../logger"; + +// IPC channel for protocol actions. Main broadcasts the open-url events to this channel. +export const ProtocolHandlerIpcPrefix = "protocol-handler"; + +export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`; +export const ProtocolHandlerExtension = `${ProtocolHandlerIpcPrefix}:extension`; +export const ProtocolHandlerInvalid = `${ProtocolHandlerIpcPrefix}:invalid`; + +/** + * These two names are long and cumbersome by design so as to decrease the chances + * of an extension using the same names. + * + * Though under the current (2021/01/18) implementation, these are never matched + * against in the final matching so their names are less of a concern. + */ +export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; +export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; + +/** + * Returned from routing attempts + */ +export enum RouteAttempt { + /** + * A handler was found in the set of registered routes + */ + MATCHED = "matched", + /** + * A handler was not found within the set of registered routes + */ + MISSING = "missing", + /** + * The extension that was matched in the route was not activated + */ + MISSING_EXTENSION = "no-extension", +} + +export function foldAttemptResults(mainAttempt: RouteAttempt, rendererAttempt: RouteAttempt): RouteAttempt { + switch (mainAttempt) { + case RouteAttempt.MATCHED: + return RouteAttempt.MATCHED; + case RouteAttempt.MISSING: + case RouteAttempt.MISSING_EXTENSION: + return rendererAttempt; + } +} + +export interface LensProtocolRouterDependencies { + readonly extensionLoader: ExtensionLoader; + readonly extensionsStore: ExtensionsStore; + readonly logger: Logger; +} + +export abstract class LensProtocolRouter { + // Map between path schemas and the handlers + protected internalRoutes = new Map(); + + public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; + + static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + + constructor(protected readonly dependencies: LensProtocolRouterDependencies) {} + + /** + * Attempts to route the given URL to all internal routes that have been registered + * @param url the parsed URL that initiated the `lens://` protocol + * @returns true if a route has been found + */ + protected _routeToInternal(url: Url>): RouteAttempt { + return this._route(this.internalRoutes.entries(), url); + } + + /** + * match against all matched URIs, returning either the first exact match or + * the most specific match if none are exact. + * @param routes the array of path schemas, handler pairs to match against + * @param url the url (in its current state) + */ + protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url>): null | [match>, RouteHandler] { + const matches: [match>, RouteHandler][] = []; + + for (const [schema, handler] of routes) { + const match = matchPath(url.pathname, { path: schema }); + + if (!match) { + continue; + } + + // prefer an exact match + if (match.isExact) { + return [match, handler]; + } + + matches.push([match, handler]); + } + + // if no exact match pick the one that is the most specific + return matches.sort(([a], [b]) => { + if (a.path === "/") { + return 1; + } + + if (b.path === "/") { + return -1; + } + + return countBy(b.path)["/"] - countBy(a.path)["/"]; + })[0] ?? null; + } + + /** + * find the most specific matching handler and call it + * @param routes the array of (path schemas, handler) pairs to match against + * @param url the url (in its current state) + */ + protected _route(routes: Iterable<[string, RouteHandler]>, url: Url>, extensionName?: string): RouteAttempt { + const route = this._findMatchingRoute(routes, url); + + if (!route) { + const data: Record = { url: url.toString() }; + + if (extensionName) { + data.extensionName = extensionName; + } + + this.dependencies.logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data); + + return RouteAttempt.MISSING; + } + + const [match, handler] = route; + + const params: RouteParams = { + pathname: match.params, + search: url.query, + }; + + if (!match.isExact) { + params.tail = url.pathname.slice(match.url.length); + } + + handler(params); + + return RouteAttempt.MATCHED; + } + + /** + * Tries to find the matching LensExtension instance + * + * Note: this needs to be async so that `main`'s overloaded version can also be async + * @param url the protocol request URI that was "open"-ed + * @returns either the found name or the instance of `LensExtension` + */ + protected async _findMatchingExtensionByName(url: Url>): Promise { + interface ExtensionUrlMatch { + [EXTENSION_PUBLISHER_MATCH]: string; + [EXTENSION_NAME_MATCH]: string; + } + + const match = matchPath(url.pathname, LensProtocolRouter.ExtensionUrlSchema); + + if (!match) { + throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url); + } + + const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; + const name = [publisher, partialName].filter(isDefined).join("/"); + + const extensionLoader = this.dependencies.extensionLoader; + + try { + /** + * Note, if `getInstanceByName` returns `null` that means we won't be getting an instance + */ + await when(() => extensionLoader.getInstanceByName(name) !== void 0, { + timeout: 5_000, + }); + } catch (error) { + this.dependencies.logger.info( + `${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed (${error})`, + ); + + return name; + } + + const extension = extensionLoader.getInstanceByName(name); + + if (!extension) { + this.dependencies.logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but does not have a class for ${ipcRenderer ? "renderer" : "main"}`); + + return name; + } + + if (!this.dependencies.extensionsStore.isEnabled(extension)) { + this.dependencies.logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); + + return name; + } + + this.dependencies.logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`); + + return extension; + } + + /** + * Find a matching extension by the first one or two path segments of `url` and then try to `_route` + * its correspondingly registered handlers. + * + * If no handlers are found or the extension is not enabled then `_missingHandlers` is called before + * checking if more handlers have been added. + * + * Note: this function modifies its argument, do not reuse + * @param url the protocol request URI that was "open"-ed + */ + protected async _routeToExtension(url: Url>): Promise { + const extension = await this._findMatchingExtensionByName(url); + + if (typeof extension === "string") { + // failed to find an extension, it returned its name + return RouteAttempt.MISSING_EXTENSION; + } + + // remove the extension name from the path name so we don't need to match on it anymore + url.set("pathname", url.pathname.slice(extension.name.length + 1)); + + try { + const handlers = iter.map(extension.protocolHandlers, ({ pathSchema, handler }) => [pathSchema, handler] as [string, RouteHandler]); + + return this._route(handlers, url, extension.name); + } catch (error) { + if (error instanceof RoutingError) { + error.extensionName = extension.name; + } + + throw error; + } + } + + /** + * Add a handler under the `lens://app` tree of routing. + * @param pathSchema the URI path schema to match against for this handler + * @param handler a function that will be called if a protocol path matches + */ + public addInternalHandler(urlSchema: string, handler: RouteHandler): this { + pathToRegexp(urlSchema); // verify now that the schema is valid + this.dependencies.logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`); + this.internalRoutes.set(urlSchema, handler); + + return this; + } + + /** + * Remove an internal protocol handler. + * @param pathSchema the path schema that the handler was registered under + */ + public removeInternalHandler(urlSchema: string): void { + this.internalRoutes.delete(urlSchema); + } +} diff --git a/packages/core/src/common/rbac.ts b/packages/core/src/common/rbac.ts new file mode 100644 index 0000000000..39edc49b60 --- /dev/null +++ b/packages/core/src/common/rbac.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type KubeResource = + "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" | "leases" | + "secrets" | "configmaps" | "ingresses" | "ingressclasses" | "networkpolicies" | "persistentvolumeclaims" | "persistentvolumes" | "storageclasses" | + "pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" | + "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets" | + "priorityclasses" | "runtimeclasses" | + "roles" | "clusterroles" | "rolebindings" | "clusterrolebindings" | "serviceaccounts"; + +export interface KubeApiResource { + kind: string; + group: string; + apiName: string; + namespaced: boolean; +} + +export interface KubeApiResourceDescriptor { + apiName: string; + group: string; +} + +export const formatKubeApiResource = (res: KubeApiResourceDescriptor) => `${res.group}/${res.apiName}`; + +export interface KubeApiResourceData { + kind: string; // resource type (e.g. "Namespace") + group: string; // api-group, if empty then "core" + namespaced: boolean; +} + +export const apiResourceRecord: Record = { + clusterroles: { + kind: "ClusterRole", + group: "rbac.authorization.k8s.io", + namespaced: false, + }, + clusterrolebindings: { + kind: "ClusterRoleBinding", + group: "rbac.authorization.k8s.io", + namespaced: false, + }, + configmaps: { + kind: "ConfigMap", + group: "v1", + namespaced: true, + }, + cronjobs: { + kind: "CronJob", + group: "batch", + namespaced: true, + }, + customresourcedefinitions: { + kind: "CustomResourceDefinition", + group: "apiextensions.k8s.io", + namespaced: false, + }, + daemonsets: { + kind: "DaemonSet", + group: "apps", + namespaced: true, + }, + deployments: { + kind: "Deployment", + group: "apps", + namespaced: true, + }, + endpoints: { + kind: "Endpoint", + group: "v1", + namespaced: true, + }, + events: { + kind: "Event", + group: "v1", + namespaced: true, + }, + horizontalpodautoscalers: { + kind: "HorizontalPodAutoscaler", + group: "autoscaling", + namespaced: true, + }, + ingresses: { + kind: "Ingress", + group: "networking.k8s.io", + namespaced: true, + }, + ingressclasses: { + kind: "IngressClass", + group: "networking.k8s.io", + namespaced: false, + }, + jobs: { + kind: "Job", + group: "batch", + namespaced: true, + }, + namespaces: { + kind: "Namespace", + group: "v1", + namespaced: false, + }, + limitranges: { + kind: "LimitRange", + group: "v1", + namespaced: true, + }, + leases: { + kind: "Lease", + group: "v1", + namespaced: true, + }, + networkpolicies: { + kind: "NetworkPolicy", + group: "networking.k8s.io", + namespaced: true, + }, + nodes: { + kind: "Node", + group: "v1", + namespaced: false, + }, + persistentvolumes: { + kind: "PersistentVolume", + group: "v1", + namespaced: false, + }, + persistentvolumeclaims: { + kind: "PersistentVolumeClaim", + group: "v1", + namespaced: true, + }, + pods: { + kind: "Pod", + group: "v1", + namespaced: true, + }, + poddisruptionbudgets: { + kind: "PodDisruptionBudget", + group: "policy", + namespaced: true, + }, + podsecuritypolicies: { + kind: "PodSecurityPolicy", + group: "policy", + namespaced: false, + }, + priorityclasses: { + kind: "PriorityClass", + group: "scheduling.k8s.io", + namespaced: false, + }, + runtimeclasses: { + kind: "RuntimeClass", + group: "node.k8s.io", + namespaced: false, + }, + resourcequotas: { + kind: "ResourceQuota", + group: "v1", + namespaced: true, + }, + replicasets: { + kind: "ReplicaSet", + group: "apps", + namespaced: true, + }, + roles: { + kind: "Role", + group: "rbac.authorization.k8s.io", + namespaced: true, + }, + rolebindings: { + kind: "RoleBinding", + group: "rbac.authorization.k8s.io", + namespaced: true, + }, + secrets: { + kind: "Secret", + group: "v1", + namespaced: true, + }, + serviceaccounts: { + kind: "ServiceAccount", + group: "v1", + namespaced: true, + }, + services: { + kind: "Service", + group: "v1", + namespaced: true, + }, + statefulsets: { + kind: "StatefulSet", + group: "apps", + namespaced: true, + }, + storageclasses: { + kind: "StorageClass", + group: "storage.k8s.io", + namespaced: false, + }, +}; diff --git a/packages/core/src/common/root-frame/root-frame-rendered-channel.ts b/packages/core/src/common/root-frame/root-frame-rendered-channel.ts new file mode 100644 index 0000000000..060ae8735c --- /dev/null +++ b/packages/core/src/common/root-frame/root-frame-rendered-channel.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { MessageChannel } from "../utils/channel/message-channel-listener-injection-token"; + +export type RootFrameHasRenderedChannel = MessageChannel; + +export const rootFrameHasRenderedChannel: RootFrameHasRenderedChannel = { + id: "root-frame-rendered", +}; diff --git a/packages/core/src/common/runnable/run-many-for.test.ts b/packages/core/src/common/runnable/run-many-for.test.ts new file mode 100644 index 0000000000..6002a149db --- /dev/null +++ b/packages/core/src/common/runnable/run-many-for.test.ts @@ -0,0 +1,660 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { createContainer, getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { Runnable } from "./run-many-for"; +import { runManyFor } from "./run-many-for"; +import { getPromiseStatus } from "../test-utils/get-promise-status"; +import { runInAction } from "mobx"; +import { flushPromises } from "../test-utils/flush-promises"; + +describe("runManyFor", () => { + describe("given no hierarchy, when running many", () => { + let runMock: AsyncFnMock<(...args: unknown[]) => Promise>; + let actualPromise: Promise; + + beforeEach(() => { + const rootDi = createContainer("irrelevant"); + + runMock = asyncFn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-injectable", + instantiate: () => ({ + id: "some-injectable", + run: () => runMock("some-call"), + }), + injectionToken: someInjectionTokenForRunnables, + }); + + const someOtherInjectable = getInjectable({ + id: "some-other-injectable", + instantiate: () => ({ + id: "some-other-injectable", + run: () => runMock("some-other-call"), + }), + injectionToken: someInjectionTokenForRunnables, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManyFor(rootDi)(someInjectionTokenForRunnables); + + actualPromise = runMany() as Promise; + }); + + it("runs all runnables at the same time", () => { + expect(runMock.mock.calls).toEqual([ + ["some-call"], + ["some-other-call"], + ]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when all runnables resolve, resolves", async () => { + await Promise.all([runMock.resolve(), runMock.resolve()]); + + expect(await actualPromise).toBe(undefined); + }); + }); + + describe("given hierarchy that is three levels deep, when running many", () => { + let runMock: AsyncFnMock<(...args: unknown[]) => Promise>; + let actualPromise: Promise; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + runMock = asyncFn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable1 = getInjectable({ + id: "some-injectable-1", + + instantiate: (di) => ({ + id: "some-injectable-1", + run: () => runMock("third-level-run"), + runAfter: di.inject(someInjectable2), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable2 = getInjectable({ + id: "some-injectable-2", + + instantiate: (di) => ({ + id: "some-injectable-2", + run: () => runMock("second-level-run"), + runAfter: di.inject(someInjectable3), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable3 = getInjectable({ + id: "some-injectable-3", + instantiate: () => ({ + id: "some-injectable-3", + run: () => runMock("first-level-run"), + }), + injectionToken: someInjectionTokenForRunnables, + }); + + di.register(someInjectable1, someInjectable2, someInjectable3); + + const runMany = runManyFor(di)(someInjectionTokenForRunnables); + + actualPromise = runMany() as Promise; + }); + + it("runs first level runnables", () => { + expect(runMock.mock.calls).toEqual([["first-level-run"]]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when first level runnables resolve", () => { + beforeEach(async () => { + runMock.mockClear(); + + await runMock.resolveSpecific(["first-level-run"]); + }); + + it("runs second level runnables", async () => { + expect(runMock.mock.calls).toEqual([["second-level-run"]]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when second level runnables resolve", () => { + beforeEach(async () => { + runMock.mockClear(); + + await runMock.resolveSpecific(["second-level-run"]); + }); + + it("runs final third level runnables", async () => { + expect(runMock.mock.calls).toEqual([["third-level-run"]]); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when final third level runnables resolve", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["third-level-run"]); + }); + + it("resolves", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(true); + }); + }); + }); + }); + }); + + it("given invalid hierarchy, when running runnables, throws", () => { + const rootDi = createContainer("irrelevant"); + + const runMock = asyncFn<(...args: unknown[]) => void>(); + + const someInjectionToken = getInjectionToken({ + id: "some-injection-token", + }); + + const someOtherInjectionToken = getInjectionToken({ + id: "some-other-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: (di) => ({ + id: "some-runnable-1", + run: () => runMock("some-runnable-1"), + runAfter: di.inject(someOtherInjectable), + }), + + injectionToken: someInjectionToken, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + id: "some-runnable-2", + run: () => runMock("some-runnable-2"), + }), + + injectionToken: someOtherInjectionToken, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManyFor(rootDi)( + someInjectionToken, + ); + + return expect(() => runMany()).rejects.toThrow( + /Runnable "some-runnable-1" is unreachable for injection token "some-injection-token": run afters "some-runnable-2" are a part of different injection tokens./, + ); + }); + + it("given partially incorrect hierarchy, when running runnables, throws", () => { + const rootDi = createContainer("irrelevant"); + + const runMock = asyncFn<(...args: unknown[]) => void>(); + + const someInjectionToken = getInjectionToken({ + id: "some-injection-token", + }); + + const someOtherInjectionToken = getInjectionToken({ + id: "some-other-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: (di) => ({ + id: "some-runnable-1", + run: () => runMock("some-runnable-1"), + runAfter: [ + di.inject(someOtherInjectable), + di.inject(someSecondInjectable), + ], + }), + + injectionToken: someInjectionToken, + }); + + const someSecondInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + id: "some-runnable-2", + run: () => runMock("some-runnable-2"), + }), + + injectionToken: someInjectionToken, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-3", + + instantiate: () => ({ + id: "some-runnable-3", + run: () => runMock("some-runnable-3"), + }), + + injectionToken: someOtherInjectionToken, + }); + + rootDi.register(someInjectable, someOtherInjectable, someSecondInjectable); + + const runMany = runManyFor(rootDi)( + someInjectionToken, + ); + + return expect(() => runMany()).rejects.toThrow( + /Runnable "some-runnable-3" is not part of the injection token "some-injection-token"/, + ); + }); + + describe("when running many with parameter", () => { + let runMock: AsyncFnMock<(...args: unknown[]) => Promise>; + + beforeEach(() => { + const rootDi = createContainer("irrelevant"); + + runMock = asyncFn(); + + const someInjectionTokenForRunnablesWithParameter = getInjectionToken< + Runnable + >({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: () => ({ + id: "some-runnable-1", + run: (parameter) => runMock("run-of-some-runnable-1", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + id: "some-runnable-2", + run: (parameter) => runMock("run-of-some-runnable-2", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManyFor(rootDi)( + someInjectionTokenForRunnablesWithParameter, + ); + + runMany("some-parameter"); + }); + + it("runs all runnables using the parameter", () => { + expect(runMock.mock.calls).toEqual([ + ["run-of-some-runnable-1", "some-parameter"], + ["run-of-some-runnable-2", "some-parameter"], + ]); + }); + }); + + describe("given multiple runAfters", () => { + let runMock: AsyncFnMock<(...args: unknown[]) => void>; + let finishingPromise: Promise; + + beforeEach(async () => { + const rootDi = createContainer("irrelevant"); + + runMock = asyncFn<(...args: unknown[]) => void>(); + + const someInjectionToken = getInjectionToken({ + id: "some-injection-token", + }); + + const runnableOneInjectable = getInjectable({ + id: "runnable-1", + instantiate: () => ({ + id: "runnable-1", + run: () => runMock("runnable-1"), + }), + injectionToken: someInjectionToken, + }); + + const runnableTwoInjectable = getInjectable({ + id: "runnable-2", + instantiate: () => ({ + id: "runnable-2", + run: () => runMock("runnable-2"), + runAfter: [], // shouldn't block being called + }), + injectionToken: someInjectionToken, + }); + + const runnableThreeInjectable = getInjectable({ + id: "runnable-3", + instantiate: (di) => ({ + id: "runnable-3", + run: () => runMock("runnable-3"), + runAfter: di.inject(runnableOneInjectable), + }), + injectionToken: someInjectionToken, + }); + + const runnableFourInjectable = getInjectable({ + id: "runnable-4", + instantiate: (di) => ({ + id: "runnable-4", + run: () => runMock("runnable-4"), + runAfter: [di.inject(runnableThreeInjectable)], // should be the same as an single item + }), + injectionToken: someInjectionToken, + }); + + const runnableFiveInjectable = getInjectable({ + id: "runnable-5", + instantiate: (di) => ({ + id: "runnable-5", + run: () => runMock("runnable-5"), + runAfter: di.inject(runnableThreeInjectable), + }), + injectionToken: someInjectionToken, + }); + + const runnableSixInjectable = getInjectable({ + id: "runnable-6", + instantiate: (di) => ({ + id: "runnable-6", + run: () => runMock("runnable-6"), + runAfter: [ + di.inject(runnableFourInjectable), + di.inject(runnableFiveInjectable), + ], + }), + injectionToken: someInjectionToken, + }); + + const runnableSevenInjectable = getInjectable({ + id: "runnable-7", + instantiate: (di) => ({ + id: "runnable-7", + run: () => runMock("runnable-7"), + runAfter: [ + di.inject(runnableFiveInjectable), + di.inject(runnableSixInjectable), + ], + }), + injectionToken: someInjectionToken, + }); + + runInAction(() => { + rootDi.register( + runnableOneInjectable, + runnableTwoInjectable, + runnableThreeInjectable, + runnableFourInjectable, + runnableFiveInjectable, + runnableSixInjectable, + runnableSevenInjectable, + ); + }); + + const runMany = runManyFor(rootDi); + const runSome = runMany(someInjectionToken); + + finishingPromise = runSome(); + + await flushPromises(); + }); + + it("should run 'runnable-1'", () => { + expect(runMock).toBeCalledWith("runnable-1"); + }); + + it("should run 'runnable-2'", () => { + expect(runMock).toBeCalledWith("runnable-2"); + }); + + it("should not run 'runnable-3'", () => { + expect(runMock).not.toBeCalledWith("runnable-3"); + }); + + describe("when 'runnable-1' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-1"]); + }); + + it("should run 'runnable-3'", () => { + expect(runMock).toBeCalledWith("runnable-3"); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(3); + }); + }); + + describe("when 'runnable-3' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-3"]); + }); + + it("should run 'runnable-4'", () => { + expect(runMock).toBeCalledWith("runnable-4"); + }); + + it("should run 'runnable-5'", () => { + expect(runMock).toBeCalledWith("runnable-5"); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(5); + }); + }); + + describe("when 'runnable-4' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-4"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(5); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(5); + }); + }); + + describe("when 'runnable-5' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-5"]); + }); + + it("should run 'runnable-6'", () => { + expect(runMock).toBeCalledWith("runnable-6"); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(6); + }); + }); + + describe("when 'runnable-6' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-6"]); + }); + + it("should run 'runnable-7'", () => { + expect(runMock).toBeCalledWith("runnable-7"); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(7); + }); + + describe("when 'runnable-7' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-7"]); + }); + + it("should resolve the runMany promise call", async () => { + await finishingPromise; + }); + }); + }); + }); + }); + }); + + describe("when 'runnable-5' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-5"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(5); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(5); + }); + }); + + describe("when 'runnable-4' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-4"]); + }); + + it("should run 'runnable-6'", () => { + expect(runMock).toBeCalledWith("runnable-6"); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(6); + }); + }); + + describe("when 'runnable-6' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-6"]); + }); + + it("should run 'runnable-7'", () => { + expect(runMock).toBeCalledWith("runnable-7"); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(7); + }); + + describe("when 'runnable-7' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-7"]); + }); + + it("should resolve the runMany promise call", async () => { + await finishingPromise; + }); + }); + }); + }); + }); + }); + }); + }); + + describe("when 'runnable-2' resolves", () => { + beforeEach(async () => { + await runMock.resolveSpecific(["runnable-2"]); + }); + + it("shouldn't call any more runnables", () => { + expect(runMock).toBeCalledTimes(2); + }); + }); + }); +}); diff --git a/packages/core/src/common/runnable/run-many-for.ts b/packages/core/src/common/runnable/run-many-for.ts new file mode 100644 index 0000000000..106cc74da1 --- /dev/null +++ b/packages/core/src/common/runnable/run-many-for.ts @@ -0,0 +1,140 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainerForInjection, InjectionToken } from "@ogre-tools/injectable"; +import type { SingleOrMany } from "../utils"; +import { getOrInsert, getOrInsertSetFor, isDefined } from "../utils"; +import * as uuid from "uuid"; +import assert from "assert"; +import type { Asyncify } from "type-fest"; +import type TypedEventEmitter from "typed-emitter"; +import EventEmitter from "events"; + +export interface Runnable { + id: string; + run: Run; + runAfter?: SingleOrMany>; +} + +type Run = (parameter: Param) => Promise | void; + +export type RunMany = (injectionToken: InjectionToken, void>) => Asyncify>; + +const computedNextEdge = (traversed: string[], graph: Map>, currentId: string, seenIds: Set) => { + seenIds.add(currentId); + const currentNode = graph.get(currentId); + + assert(currentNode, `Runnable graph does not contain node with id="${currentId}"`); + + for (const nextId of currentNode.values()) { + if (traversed.includes(nextId)) { + throw new Error(`Cycle in runnable graph: "${traversed.join(`" -> "`)}" -> "${nextId}"`); + } + + computedNextEdge([...traversed, nextId], graph, nextId, seenIds); + } +}; + +const verifyRunnablesAreDAG = (injectionToken: InjectionToken, void>, runnables: Runnable[]) => { + const rootId = uuid.v4(); + const runnableGraph = new Map>(); + const seenIds = new Set(); + const addRunnableId = getOrInsertSetFor(runnableGraph); + + // Build the Directed graph + for (const runnable of runnables) { + addRunnableId(runnable.id); + + if (!runnable.runAfter || (Array.isArray(runnable.runAfter) && runnable.runAfter.length === 0)) { + addRunnableId(rootId).add(runnable.id); + } else if (Array.isArray(runnable.runAfter)) { + for (const parentRunnable of runnable.runAfter) { + addRunnableId(parentRunnable.id).add(runnable.id); + } + } else { + addRunnableId(runnable.runAfter.id).add(runnable.id); + } + } + + addRunnableId(rootId); + + // Do a DFS to find any cycles + computedNextEdge([], runnableGraph, rootId, seenIds); + + for (const id of runnableGraph.keys()) { + if (!seenIds.has(id)) { + const runnable = runnables.find(runnable => runnable.id === id); + + if (!runnable) { + throw new Error(`Runnable "${id}" is not part of the injection token "${injectionToken.id}"`); + } + + const runAfters = [runnable.runAfter] + .flat() + .filter(isDefined) + .map(runnable => runnable.id) + .join('", "'); + + throw new Error(`Runnable "${id}" is unreachable for injection token "${injectionToken.id}": run afters "${runAfters}" are a part of different injection tokens.`); + } + } +}; + +interface BarrierEvent { + finish: (id: string) => void; +} + +class DynamicBarrier { + private readonly finishedIds = new Map>(); + private readonly events: TypedEventEmitter = new EventEmitter(); + + private initFinishingPromise(id: string): Promise { + return getOrInsert(this.finishedIds, id, new Promise(resolve => { + const handler = (finishedId: string) => { + if (finishedId === id) { + resolve(); + this.events.removeListener("finish", handler); + } + }; + + this.events.addListener("finish", handler); + })); + } + + setFinished(id: string): void { + void this.initFinishingPromise(id); + + this.events.emit("finish", id); + } + + async blockOn(id: string): Promise { + await this.initFinishingPromise(id); + } +} + +const executeRunnableWith = (param: Param) => { + const barrier = new DynamicBarrier(); + + return async (runnable: Runnable): Promise => { + const parentRunnables = [runnable.runAfter].flat().filter(isDefined); + + for (const parentRunnable of parentRunnables) { + await barrier.blockOn(parentRunnable.id); + } + + await runnable.run(param); + barrier.setFinished(runnable.id); + }; +}; + +export function runManyFor(di: DiContainerForInjection): RunMany { + return (injectionToken: InjectionToken, void>) => async (param: Param) => { + const executeRunnable = executeRunnableWith(param); + const allRunnables = di.injectMany(injectionToken); + + verifyRunnablesAreDAG(injectionToken, allRunnables); + + await Promise.all(allRunnables.map(executeRunnable)); + }; +} diff --git a/packages/core/src/common/runnable/run-many-sync-for.test.ts b/packages/core/src/common/runnable/run-many-sync-for.test.ts new file mode 100644 index 0000000000..3aadcad0bf --- /dev/null +++ b/packages/core/src/common/runnable/run-many-sync-for.test.ts @@ -0,0 +1,212 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { createContainer, getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { RunnableSync } from "./run-many-sync-for"; +import { runManySyncFor } from "./run-many-sync-for"; + +describe("runManySyncFor", () => { + describe("given hierarchy, when running many", () => { + let runMock: jest.Mock; + + beforeEach(() => { + const rootDi = createContainer("irrelevant"); + + runMock = jest.fn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-injectable", + instantiate: () => ({ + id: "some-injectable", + run: () => runMock("some-call"), + }), + injectionToken: someInjectionTokenForRunnables, + }); + + const someOtherInjectable = getInjectable({ + id: "some-other-injectable", + instantiate: () => ({ + id: "some-other-injectable", + run: () => runMock("some-other-call"), + }), + injectionToken: someInjectionTokenForRunnables, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManySyncFor(rootDi)(someInjectionTokenForRunnables); + + runMany(); + }); + + it("runs all runnables at the same time", () => { + expect(runMock.mock.calls).toEqual([ + ["some-call"], + ["some-other-call"], + ]); + }); + }); + + describe("given hierarchy that is three levels deep, when running many", () => { + let runMock: jest.Mock<(arg: string) => void>; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + runMock = jest.fn(); + + const someInjectionTokenForRunnables = getInjectionToken({ + id: "some-injection-token", + }); + + const someInjectable1 = getInjectable({ + id: "some-injectable-1", + + instantiate: (di) => ({ + id: "some-injectable-1", + run: () => void runMock("third-level-run"), + runAfter: di.inject(someInjectable2), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable2 = getInjectable({ + id: "some-injectable-2", + + instantiate: (di) => ({ + id: "some-injectable-2", + run: () => void runMock("second-level-run"), + runAfter: di.inject(someInjectable3), + }), + + injectionToken: someInjectionTokenForRunnables, + }); + + const someInjectable3 = getInjectable({ + id: "some-injectable-3", + instantiate: () => ({ + id: "some-injectable-3", + run: () => void runMock("first-level-run"), + }), + injectionToken: someInjectionTokenForRunnables, + }); + + di.register(someInjectable1, someInjectable2, someInjectable3); + + const runMany = runManySyncFor(di)(someInjectionTokenForRunnables); + + runMany(); + }); + + it("runs runnables in order", () => { + expect(runMock.mock.calls).toEqual([["first-level-run"], ["second-level-run"], ["third-level-run"]]); + }); + }); + + it("given invalid hierarchy, when running runnables, throws", () => { + const rootDi = createContainer("irrelevant"); + + const runMock = jest.fn(); + + const someInjectionToken = getInjectionToken({ + id: "some-injection-token", + }); + + const someOtherInjectionToken = getInjectionToken({ + id: "some-other-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: (di) => ({ + id: "some-runnable-1", + run: () => runMock("some-runnable-1"), + runAfter: di.inject(someOtherInjectable), + }), + + injectionToken: someInjectionToken, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + id: "some-runnable-2", + run: () => runMock("some-runnable-2"), + }), + + injectionToken: someOtherInjectionToken, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManySyncFor(rootDi)( + someInjectionToken, + ); + + return expect(() => runMany()).toThrow( + /Tried to get a composite but encountered missing parent ids: "some-runnable-2".\n\nAvailable parent ids are:\n"[0-9a-z-]+",\n"some-runnable-1"/, + ); + }); + + describe("when running many with parameter", () => { + let runMock: jest.Mock<(arg: string, arg2: string) => undefined>; + + beforeEach(() => { + const rootDi = createContainer("irrelevant"); + + runMock = jest.fn(); + + const someInjectionTokenForRunnablesWithParameter = getInjectionToken< + RunnableSync + >({ + id: "some-injection-token", + }); + + const someInjectable = getInjectable({ + id: "some-runnable-1", + + instantiate: () => ({ + id: "some-runnable-1", + run: (parameter) => void runMock("run-of-some-runnable-1", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + const someOtherInjectable = getInjectable({ + id: "some-runnable-2", + + instantiate: () => ({ + id: "some-runnable-2", + run: (parameter) => void runMock("run-of-some-runnable-2", parameter), + }), + + injectionToken: someInjectionTokenForRunnablesWithParameter, + }); + + rootDi.register(someInjectable, someOtherInjectable); + + const runMany = runManySyncFor(rootDi)( + someInjectionTokenForRunnablesWithParameter, + ); + + runMany("some-parameter"); + }); + + it("runs all runnables using the parameter", () => { + expect(runMock.mock.calls).toEqual([ + ["run-of-some-runnable-1", "some-parameter"], + ["run-of-some-runnable-2", "some-parameter"], + ]); + }); + }); +}); + diff --git a/packages/core/src/common/runnable/run-many-sync-for.ts b/packages/core/src/common/runnable/run-many-sync-for.ts new file mode 100644 index 0000000000..08dba2f72d --- /dev/null +++ b/packages/core/src/common/runnable/run-many-sync-for.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainerForInjection, InjectionToken } from "@ogre-tools/injectable"; +import type { Composite } from "../utils/composite/get-composite/get-composite"; +import { getCompositeFor } from "../utils/composite/get-composite/get-composite"; +import * as uuid from "uuid"; + +export interface RunnableSync { + id: string; + run: RunSync; + runAfter?: RunnableSync; +} + +/** + * NOTE: this is the worse of two evils. This makes sure that `RunnableSync` always is sync. + * If the return type is `void` instead then async functions (those return `Promise`) can + * coerce to it. + */ +type RunSync = (parameter: Param) => undefined; + +export type RunManySync = (injectionToken: InjectionToken, void>) => RunSync; + +function runCompositeRunnableSyncs(param: Param, composite: Composite>): undefined { + composite.value.run(param); + composite.children.map(composite => runCompositeRunnableSyncs(param, composite)); + + return undefined; +} + +export function runManySyncFor(di: DiContainerForInjection): RunManySync { + return (injectionToken: InjectionToken, void>) => (param: Param): undefined => { + const allRunnables = di.injectMany(injectionToken); + const rootId = uuid.v4(); + const getCompositeRunnables = getCompositeFor>({ + getId: (runnable) => runnable.id, + getParentId: (runnable) => ( + runnable.id === rootId + ? undefined + : runnable.runAfter?.id ?? rootId + ), + }); + const composite = getCompositeRunnables([ + // This is a dummy runnable to conform to the requirements of `getCompositeFor` to only have one root + { + id: rootId, + run: () => undefined, + }, + ...allRunnables, + ]); + + return runCompositeRunnableSyncs(param, composite); + }; +} diff --git a/packages/core/src/common/terminal/channels.ts b/packages/core/src/common/terminal/channels.ts new file mode 100644 index 0000000000..f958c9c696 --- /dev/null +++ b/packages/core/src/common/terminal/channels.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +export enum TerminalChannels { + STDIN = "stdin", + STDOUT = "stdout", + CONNECTED = "connected", + RESIZE = "resize", + PING = "ping", +} + +export type TerminalMessage = { + type: TerminalChannels.STDIN; + data: string; +} | { + type: TerminalChannels.STDOUT; + data: string; +} | { + type: TerminalChannels.CONNECTED; +} | { + type: TerminalChannels.RESIZE; + data: { + width: number; + height: number; + }; +} | { + type: TerminalChannels.PING; +}; diff --git a/packages/core/src/common/test-utils/flush-promises.ts b/packages/core/src/common/test-utils/flush-promises.ts new file mode 100644 index 0000000000..c2fdeff99e --- /dev/null +++ b/packages/core/src/common/test-utils/flush-promises.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { setImmediate } from "timers"; + +export const flushPromises = () => new Promise(setImmediate); diff --git a/packages/core/src/common/test-utils/get-global-override-for-function.ts b/packages/core/src/common/test-utils/get-global-override-for-function.ts new file mode 100644 index 0000000000..238ee5621a --- /dev/null +++ b/packages/core/src/common/test-utils/get-global-override-for-function.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; +import { getGlobalOverride } from "./get-global-override"; +import { camelCase } from "lodash/fp"; + +export const getGlobalOverrideForFunction = ( + injectable: Injectable, +) => + getGlobalOverride(injectable, () => (...args: any[]) => { + console.warn( + `Tried to invoke a function "${injectable.id}" without override. The args were:`, + args, + ); + + throw new Error( + `Tried to invoke a function "${ + injectable.id + }" without override. Add eg. "di.override(${camelCase( + injectable.id, + )}Mock)" to the unit test interested in this.`, + ); + }); diff --git a/packages/core/src/common/test-utils/get-global-override.ts b/packages/core/src/common/test-utils/get-global-override.ts new file mode 100644 index 0000000000..ac3c86a33e --- /dev/null +++ b/packages/core/src/common/test-utils/get-global-override.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; + +export interface GlobalOverride { + injectable: Injectable; + overridingInstantiate: any; +} + +export const getGlobalOverride = >( + injectable: T, + overridingInstantiate: T["instantiate"], +) => ({ + injectable, + overridingInstantiate, + }); diff --git a/packages/core/src/common/test-utils/get-promise-status.ts b/packages/core/src/common/test-utils/get-promise-status.ts new file mode 100644 index 0000000000..8c171fbe54 --- /dev/null +++ b/packages/core/src/common/test-utils/get-promise-status.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { flushPromises } from "./flush-promises"; + +export const getPromiseStatus = async (promise: Promise) => { + const status = { fulfilled: false }; + + promise.finally(() => { + status.fulfilled = true; + }); + + await flushPromises(); + + return status; +}; diff --git a/packages/core/src/common/test-utils/use-fake-time.ts b/packages/core/src/common/test-utils/use-fake-time.ts new file mode 100644 index 0000000000..e455984861 --- /dev/null +++ b/packages/core/src/common/test-utils/use-fake-time.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { act } from "@testing-library/react"; + +let usingFakeTime = false; + +export const advanceFakeTime = (milliseconds: number) => { + if (!usingFakeTime) { + throw new Error("Tried to advance fake time but it was not enabled. Call useFakeTime() first."); + } + + act(() => { + jest.advanceTimersByTime(milliseconds); + }); +}; + +export const testUsingFakeTime = (dateTime = "2015-10-21T07:28:00Z") => { + usingFakeTime = true; + + jest.useFakeTimers(); + + jest.setSystemTime(new Date(dateTime)); +}; diff --git a/packages/core/src/common/user-store/current-timezone.global-override-for-injectable.ts b/packages/core/src/common/user-store/current-timezone.global-override-for-injectable.ts new file mode 100644 index 0000000000..6056074d3c --- /dev/null +++ b/packages/core/src/common/user-store/current-timezone.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import currentTimezoneInjectable from "./current-timezone.injectable"; + +export default getGlobalOverride(currentTimezoneInjectable, () => "Etc/GMT"); diff --git a/packages/core/src/common/user-store/current-timezone.injectable.ts b/packages/core/src/common/user-store/current-timezone.injectable.ts new file mode 100644 index 0000000000..6a6043eaf9 --- /dev/null +++ b/packages/core/src/common/user-store/current-timezone.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import moment from "moment-timezone"; + +const currentTimezoneInjectable = getInjectable({ + id: "current-timezone", + instantiate: () => moment.tz.guess(true), + causesSideEffects: true, +}); + +export default currentTimezoneInjectable; diff --git a/packages/core/src/common/user-store/file-name-migration.global-override-for-injectable.ts b/packages/core/src/common/user-store/file-name-migration.global-override-for-injectable.ts new file mode 100644 index 0000000000..bb0ac054f3 --- /dev/null +++ b/packages/core/src/common/user-store/file-name-migration.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import userStoreFileNameMigrationInjectable from "./file-name-migration.injectable"; + +export default getGlobalOverride(userStoreFileNameMigrationInjectable, () => async () => {}); diff --git a/packages/core/src/common/user-store/file-name-migration.injectable.ts b/packages/core/src/common/user-store/file-name-migration.injectable.ts new file mode 100644 index 0000000000..106f559ef0 --- /dev/null +++ b/packages/core/src/common/user-store/file-name-migration.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import fse from "fs-extra"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { isErrnoException } from "../utils"; +import { getInjectable } from "@ogre-tools/injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; + +export type UserStoreFileNameMigration = () => Promise; + +const userStoreFileNameMigrationInjectable = getInjectable({ + id: "user-store-file-name-migration", + instantiate: (di): UserStoreFileNameMigration => { + const userDataPath = di.inject(directoryForUserDataInjectable); + const joinPaths = di.inject(joinPathsInjectable); + const configJsonPath = joinPaths(userDataPath, "config.json"); + const lensUserStoreJsonPath = joinPaths(userDataPath, "lens-user-store.json"); + + return async () => { + try { + await fse.move(configJsonPath, lensUserStoreJsonPath); + } catch (error) { + if (error instanceof Error && error.message === "dest already exists.") { + await fse.remove(configJsonPath); + } else if (isErrnoException(error) && error.code === "ENOENT" && error.path === configJsonPath) { + // (No such file or directory) + return; // file already moved + } else { + // pass other errors along + throw error; + } + } + }; + }, + causesSideEffects: true, +}); + +export default userStoreFileNameMigrationInjectable; diff --git a/packages/core/src/common/user-store/https-proxy.injectable.ts b/packages/core/src/common/user-store/https-proxy.injectable.ts new file mode 100644 index 0000000000..30569d4e77 --- /dev/null +++ b/packages/core/src/common/user-store/https-proxy.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userStoreInjectable from "./user-store.injectable"; + +const httpsProxyConfigurationInjectable = getInjectable({ + id: "https-proxy-configuration", + instantiate: (di) => { + const userStore = di.inject(userStoreInjectable); + + return computed(() => userStore.httpsProxy); + }, +}); + +export default httpsProxyConfigurationInjectable; diff --git a/packages/core/src/common/user-store/index.ts b/packages/core/src/common/user-store/index.ts new file mode 100644 index 0000000000..026167519b --- /dev/null +++ b/packages/core/src/common/user-store/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./user-store"; +export type { KubeconfigSyncEntry, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers"; diff --git a/packages/core/src/common/user-store/kubeconfig-syncs.injectable.ts b/packages/core/src/common/user-store/kubeconfig-syncs.injectable.ts new file mode 100644 index 0000000000..7327b9d8e4 --- /dev/null +++ b/packages/core/src/common/user-store/kubeconfig-syncs.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import userStoreInjectable from "./user-store.injectable"; + +const kubeconfigSyncsInjectable = getInjectable({ + id: "kubeconfig-syncs", + instantiate: (di) => di.inject(userStoreInjectable).syncKubeconfigEntries, +}); + +export default kubeconfigSyncsInjectable; diff --git a/packages/core/src/common/user-store/lens-color-theme.injectable.ts b/packages/core/src/common/user-store/lens-color-theme.injectable.ts new file mode 100644 index 0000000000..5b48de1a37 --- /dev/null +++ b/packages/core/src/common/user-store/lens-color-theme.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userStoreInjectable from "./user-store.injectable"; + +export type LensColorThemePreference = { + useSystemTheme: true; +} | { + useSystemTheme: false; + lensThemeId: string; +}; + +const lensColorThemePreferenceInjectable = getInjectable({ + id: "lens-color-theme-preference", + instantiate: (di) => { + const userStore = di.inject(userStoreInjectable); + + return computed((): LensColorThemePreference => { + // TODO: remove magic strings + if (userStore.colorTheme === "system") { + return { + useSystemTheme: true, + }; + } + + return { + useSystemTheme: false, + lensThemeId: userStore.colorTheme, + }; + }); + }, +}); + +export default lensColorThemePreferenceInjectable; diff --git a/packages/core/src/common/user-store/migrations-token.ts b/packages/core/src/common/user-store/migrations-token.ts new file mode 100644 index 0000000000..f3959beb3a --- /dev/null +++ b/packages/core/src/common/user-store/migrations-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MigrationDeclaration } from "../base-store/migrations.injectable"; + +export const userStoreMigrationInjectionToken = getInjectionToken({ + id: "user-store-migration-token", +}); diff --git a/packages/core/src/common/user-store/preference-descriptors.injectable.ts b/packages/core/src/common/user-store/preference-descriptors.injectable.ts new file mode 100644 index 0000000000..35815dbbea --- /dev/null +++ b/packages/core/src/common/user-store/preference-descriptors.injectable.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { merge } from "lodash"; +import type { ObservableMap } from "mobx"; +import { observable } from "mobx"; +import homeDirectoryPathInjectable from "../os/home-directory-path.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; +import { defaultThemeId } from "../vars"; +import currentTimezoneInjectable from "./current-timezone.injectable"; +import type { EditorConfiguration, ExtensionRegistry, KubeconfigSyncEntry, KubeconfigSyncValue, TerminalConfig } from "./preferences-helpers"; +import { defaultExtensionRegistryUrlLocation, defaultEditorConfig, defaultTerminalConfig, defaultPackageMirror, getPreferenceDescriptor, packageMirrors } from "./preferences-helpers"; + +export type PreferenceDescriptors = ReturnType; + +const userStorePreferenceDescriptorsInjectable = getInjectable({ + id: "user-store-preference-descriptors", + instantiate: (di) => { + const currentTimezone = di.inject(currentTimezoneInjectable); + const joinPaths = di.inject(joinPathsInjectable); + const homeDirectoryPath = di.inject(homeDirectoryPathInjectable); + + const mainKubeFolderPath = joinPaths(homeDirectoryPath, ".kube"); + + return ({ + httpsProxy: getPreferenceDescriptor({ + fromStore: val => val, + toStore: val => val || undefined, + }), + shell: getPreferenceDescriptor({ + fromStore: val => val, + toStore: val => val || undefined, + }), + colorTheme: getPreferenceDescriptor({ + fromStore: val => val || defaultThemeId, + toStore: val => !val || val === defaultThemeId + ? undefined + : val, + }), + terminalTheme: getPreferenceDescriptor({ + fromStore: val => val || "", + toStore: val => val || undefined, + }), + localeTimezone: getPreferenceDescriptor({ + fromStore: val => val || currentTimezone, + toStore: val => !val || val === currentTimezone + ? undefined + : val, + }), + allowUntrustedCAs: getPreferenceDescriptor({ + fromStore: val => val ?? false, + toStore: val => !val + ? undefined + : val, + }), + allowErrorReporting: getPreferenceDescriptor({ + fromStore: val => val ?? true, + toStore: val => val + ? undefined + : val, + }), + downloadMirror: getPreferenceDescriptor({ + fromStore: val => !val || !packageMirrors.has(val) + ? defaultPackageMirror + : val, + toStore: val => val === defaultPackageMirror + ? undefined + : val, + }), + downloadKubectlBinaries: getPreferenceDescriptor({ + fromStore: val => val ?? true, + toStore: val => val + ? undefined + : val, + }), + downloadBinariesPath: getPreferenceDescriptor({ + fromStore: val => val, + toStore: val => val || undefined, + }), + kubectlBinariesPath: getPreferenceDescriptor({ + fromStore: val => val, + toStore: val => val || undefined, + }), + openAtLogin: getPreferenceDescriptor({ + fromStore: val => val ?? false, + toStore: val => !val + ? undefined + : val, + }), + terminalCopyOnSelect: getPreferenceDescriptor({ + fromStore: val => val ?? false, + toStore: val => !val + ? undefined + : val, + }), + hiddenTableColumns: getPreferenceDescriptor<[string, string[]][], Map>>({ + fromStore: (val = []) => new Map( + val.map(([tableId, columnIds]) => [tableId, new Set(columnIds)]), + ), + toStore: (val) => { + const res: [string, string[]][] = []; + + for (const [table, columns] of val) { + if (columns.size) { + res.push([table, Array.from(columns)]); + } + } + + return res.length ? res : undefined; + }, + }), + syncKubeconfigEntries: getPreferenceDescriptor>({ + fromStore: val => observable.map( + val?.map(({ filePath, ...rest }) => [filePath, rest]) + ?? [[mainKubeFolderPath, {}]], + ), + toStore: val => val.size === 1 && val.has(mainKubeFolderPath) + ? undefined + : Array.from(val, ([filePath, rest]) => ({ filePath, ...rest })), + }), + editorConfiguration: getPreferenceDescriptor, EditorConfiguration>({ + fromStore: val => merge(defaultEditorConfig, val), + toStore: val => val, + }), + terminalConfig: getPreferenceDescriptor, TerminalConfig>({ + fromStore: val => merge(defaultTerminalConfig, val), + toStore: val => val, + }), + extensionRegistryUrl: getPreferenceDescriptor({ + fromStore: val => val ?? { + location: defaultExtensionRegistryUrlLocation, + }, + toStore: val => val.location === defaultExtensionRegistryUrlLocation + ? undefined + : val, + }), + }) as const; + }, +}); + +export default userStorePreferenceDescriptorsInjectable; diff --git a/packages/core/src/common/user-store/preferences-helpers.ts b/packages/core/src/common/user-store/preferences-helpers.ts new file mode 100644 index 0000000000..5bdc2a3852 --- /dev/null +++ b/packages/core/src/common/user-store/preferences-helpers.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { editor } from "monaco-editor"; +import { defaultEditorFontFamily, defaultFontSize, defaultTerminalFontFamily } from "../vars"; +import type { PreferenceDescriptors } from "./preference-descriptors.injectable"; + +export interface KubeconfigSyncEntry extends KubeconfigSyncValue { + filePath: string; +} + +export interface KubeconfigSyncValue { +} +export interface TerminalConfig { + fontSize: number; + fontFamily: string; +} + +export const defaultTerminalConfig: TerminalConfig = { + fontSize: defaultFontSize, + fontFamily: defaultTerminalFontFamily, +}; + +interface BaseEditorConfiguration extends Required> { + lineNumbers: NonNullable>; +} + +export type EditorConfiguration = Required; + +export const defaultEditorConfig: EditorConfiguration = { + tabSize: 2, + lineNumbers: "on", + fontSize: defaultFontSize, + fontFamily: defaultEditorFontFamily, + minimap: { + enabled: true, + side: "right", + }, +}; + +export type StoreType

= P extends PreferenceDescription + ? Store + : never; + +export interface PreferenceDescription { + fromStore(val: T | undefined): R; + toStore(val: R): T | undefined; +} + +export const getPreferenceDescriptor = (desc: PreferenceDescription) => desc; + + +export interface DownloadMirror { + url: string; + label: string; + platforms: Set; +} + +export const defaultPackageMirror = "default"; +const defaultDownloadMirrorData: DownloadMirror = { + url: "https://storage.googleapis.com/kubernetes-release/release", + label: "Default (Google)", + platforms: new Set(["darwin", "win32", "linux"]), +}; + +export const packageMirrors = new Map([ + [defaultPackageMirror, defaultDownloadMirrorData], + ["china", { + url: "https://mirror.azure.cn/kubernetes/kubectl", + label: "China (Azure)", + platforms: new Set(["win32", "linux"]), + }], +]); + +export type ExtensionRegistryLocation = "default" | "npmrc" | "custom"; + +export type ExtensionRegistry = { + location: "default" | "npmrc"; + customUrl?: undefined; +} | { + location: "custom"; + customUrl: string; +}; + +export const defaultExtensionRegistryUrlLocation = "default"; +export const defaultExtensionRegistryUrl = "https://registry.npmjs.org"; + +type PreferencesModelType = PreferenceDescriptors[field] extends PreferenceDescription ? T : never; +type UserStoreModelType = PreferenceDescriptors[field] extends PreferenceDescription ? T : never; + +export type UserStoreFlatModel = { + [field in keyof PreferenceDescriptors]: UserStoreModelType; +}; + +export type UserPreferencesModel = { + [field in keyof PreferenceDescriptors]: PreferencesModelType; +} & { updateChannel: string }; diff --git a/packages/core/src/common/user-store/shell-setting.injectable.ts b/packages/core/src/common/user-store/shell-setting.injectable.ts new file mode 100644 index 0000000000..f93a4e5874 --- /dev/null +++ b/packages/core/src/common/user-store/shell-setting.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userInfoInjectable from "./user-info.injectable"; +import userStoreInjectable from "./user-store.injectable"; + +const userShellSettingInjectable = getInjectable({ + id: "user-shell-setting", + instantiate: (di) => { + const userStore = di.inject(userStoreInjectable); + const userInfo = di.inject(userInfoInjectable); + + return computed(() => userStore.shell || userInfo.shell); + }, +}); + +export default userShellSettingInjectable; diff --git a/packages/core/src/common/user-store/terminal-config.injectable.ts b/packages/core/src/common/user-store/terminal-config.injectable.ts new file mode 100644 index 0000000000..6f5be75eaf --- /dev/null +++ b/packages/core/src/common/user-store/terminal-config.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { toJS } from "../utils"; +import userStoreInjectable from "./user-store.injectable"; + +const terminalConfigInjectable = getInjectable({ + id: "terminal-config", + instantiate: (di) => { + const store = di.inject(userStoreInjectable); + + return computed(() => toJS(store.terminalConfig)); + }, +}); + +export default terminalConfigInjectable; diff --git a/packages/core/src/common/user-store/terminal-copy-on-select.injectable.ts b/packages/core/src/common/user-store/terminal-copy-on-select.injectable.ts new file mode 100644 index 0000000000..543a4f73b9 --- /dev/null +++ b/packages/core/src/common/user-store/terminal-copy-on-select.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userStoreInjectable from "./user-store.injectable"; + +const terminalCopyOnSelectInjectable = getInjectable({ + id: "terminal-copy-on-select", + instantiate: (di) => { + const store = di.inject(userStoreInjectable); + + return computed(() => store.terminalCopyOnSelect); + }, +}); + +export default terminalCopyOnSelectInjectable; diff --git a/packages/core/src/common/user-store/terminal-theme.injectable.ts b/packages/core/src/common/user-store/terminal-theme.injectable.ts new file mode 100644 index 0000000000..a0a00c3253 --- /dev/null +++ b/packages/core/src/common/user-store/terminal-theme.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userStoreInjectable from "./user-store.injectable"; + +export type TerminalThemePreference = { + matchLensTheme: true; +} | { + matchLensTheme: false; + themeId: string; +}; + +const terminalThemePreferenceInjectable = getInjectable({ + id: "terminal-theme-preference", + instantiate: (di) => { + const userStore = di.inject(userStoreInjectable); + + return computed((): TerminalThemePreference => { + // NOTE: remove use of magic strings + if (!userStore.terminalTheme) { + return { + matchLensTheme: true, + }; + } + + return { + matchLensTheme: false, + themeId: userStore.terminalTheme, + }; + }); + }, +}); + +export default terminalThemePreferenceInjectable; diff --git a/packages/core/src/common/user-store/user-info.global-override-for-injectable.ts b/packages/core/src/common/user-store/user-info.global-override-for-injectable.ts new file mode 100644 index 0000000000..21fb26f8a9 --- /dev/null +++ b/packages/core/src/common/user-store/user-info.global-override-for-injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import userInfoInjectable from "./user-info.injectable"; + +export default getGlobalOverride(userInfoInjectable, () => ({ + gid: 1, + homedir: "/some-home-dir", + shell: "bash", + uid: 2, + username: "some-user-name", +})); diff --git a/packages/core/src/common/user-store/user-info.injectable.ts b/packages/core/src/common/user-store/user-info.injectable.ts new file mode 100644 index 0000000000..b096da03c5 --- /dev/null +++ b/packages/core/src/common/user-store/user-info.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { userInfo } from "os"; + +const userInfoInjectable = getInjectable({ + id: "user-info", + instantiate: () => userInfo(), + causesSideEffects: true, +}); + +export default userInfoInjectable; diff --git a/packages/core/src/common/user-store/user-store.injectable.ts b/packages/core/src/common/user-store/user-store.injectable.ts new file mode 100644 index 0000000000..3b45b03b1d --- /dev/null +++ b/packages/core/src/common/user-store/user-store.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { UserStore } from "./user-store"; +import selectedUpdateChannelInjectable from "../../features/application-update/common/selected-update-channel/selected-update-channel.injectable"; +import emitAppEventInjectable from "../app-event-bus/emit-event.injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; +import loggerInjectable from "../logger.injectable"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import storeMigrationsInjectable from "../base-store/migrations.injectable"; +import { userStoreMigrationInjectionToken } from "./migrations-token"; +import { baseStoreIpcChannelPrefixesInjectionToken } from "../base-store/channel-prefix"; +import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../base-store/disable-sync"; +import { persistStateToConfigInjectionToken } from "../base-store/save-to-file"; +import getBasenameOfPathInjectable from "../path/get-basename.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../utils/channel/enlist-message-channel-listener-injection-token"; +import userStorePreferenceDescriptorsInjectable from "./preference-descriptors.injectable"; + +const userStoreInjectable = getInjectable({ + id: "user-store", + + instantiate: (di) => new UserStore({ + selectedUpdateChannel: di.inject(selectedUpdateChannelInjectable), + emitAppEvent: di.inject(emitAppEventInjectable), + directoryForUserData: di.inject(directoryForUserDataInjectable), + getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable), + logger: di.inject(loggerInjectable), + storeMigrationVersion: di.inject(storeMigrationVersionInjectable), + migrations: di.inject(storeMigrationsInjectable, userStoreMigrationInjectionToken), + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken), + persistStateToConfig: di.inject(persistStateToConfigInjectionToken), + enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken), + shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), + preferenceDescriptors: di.inject(userStorePreferenceDescriptorsInjectable), + }), +}); + +export default userStoreInjectable; diff --git a/packages/core/src/common/user-store/user-store.ts b/packages/core/src/common/user-store/user-store.ts new file mode 100644 index 0000000000..8979ba3351 --- /dev/null +++ b/packages/core/src/common/user-store/user-store.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, observable, makeObservable, isObservableArray, isObservableSet, isObservableMap } from "mobx"; +import type { BaseStoreDependencies } from "../base-store/base-store"; +import { BaseStore } from "../base-store/base-store"; +import { getOrInsertSet, toggle, toJS, object } from "../../renderer/utils"; +import type { UserPreferencesModel, StoreType } from "./preferences-helpers"; +import type { EmitAppEvent } from "../app-event-bus/emit-event.injectable"; + +// TODO: Remove coupling with Feature +import type { SelectedUpdateChannel } from "../../features/application-update/common/selected-update-channel/selected-update-channel.injectable"; +import type { ReleaseChannel } from "../../features/application-update/common/update-channels"; +import type { PreferenceDescriptors } from "./preference-descriptors.injectable"; + +export interface UserStoreModel { + preferences: UserPreferencesModel; +} + +interface Dependencies extends BaseStoreDependencies { + readonly selectedUpdateChannel: SelectedUpdateChannel; + readonly preferenceDescriptors: PreferenceDescriptors; + emitAppEvent: EmitAppEvent; +} + +export class UserStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { + constructor(protected readonly dependencies: Dependencies) { + super(dependencies, { + configName: "lens-user-store", + }); + + makeObservable(this); + } + + /** + * @deprecated No longer used + */ + @observable seenContexts = observable.set(); + + /** + * @deprecated No longer used + */ + @observable newContexts = observable.set(); + + @observable allowErrorReporting!: StoreType; + @observable allowUntrustedCAs!: StoreType; + @observable colorTheme!: StoreType; + @observable terminalTheme!: StoreType; + @observable localeTimezone!: StoreType; + @observable downloadMirror!: StoreType; + @observable httpsProxy!: StoreType; + @observable shell!: StoreType; + @observable downloadBinariesPath!: StoreType; + @observable kubectlBinariesPath!: StoreType; + @observable terminalCopyOnSelect!: StoreType; + @observable terminalConfig!: StoreType; + @observable extensionRegistryUrl!: StoreType; + + /** + * Download kubectl binaries matching cluster version + */ + @observable downloadKubectlBinaries!: StoreType; + + /** + * Whether the application should open itself at login. + */ + @observable openAtLogin!: StoreType; + + /** + * The column IDs under each configurable table ID that have been configured + * to not be shown + */ + @observable hiddenTableColumns!: StoreType; + + /** + * Monaco editor configs + */ + @observable editorConfiguration!: StoreType; + + /** + * The set of file/folder paths to be synced + */ + @observable syncKubeconfigEntries!: StoreType; + + /** + * Checks if a column (by ID) for a table (by ID) is configured to be hidden + * @param tableId The ID of the table to be checked against + * @param columnIds The list of IDs the check if one is hidden + * @returns true if at least one column under the table is set to hidden + */ + isTableColumnHidden(tableId: string, ...columnIds: (string | undefined)[]): boolean { + if (columnIds.length === 0) { + return false; + } + + const config = this.hiddenTableColumns.get(tableId); + + if (!config) { + return false; + } + + return columnIds.some(columnId => columnId && config.has(columnId)); + } + + /** + * Toggles the hidden configuration of a table's column + */ + toggleTableColumnVisibility(tableId: string, columnId: string) { + toggle(getOrInsertSet(this.hiddenTableColumns, tableId), columnId); + } + + @action + resetTheme() { + this.colorTheme = this.dependencies.preferenceDescriptors.colorTheme.fromStore(undefined); + } + + @action + protected fromStore({ preferences }: Partial = {}) { + this.dependencies.logger.debug("UserStore.fromStore()", { preferences }); + + for (const [key, { fromStore }] of object.entries(this.dependencies.preferenceDescriptors)) { + const curVal = this[key]; + const newVal = fromStore((preferences)?.[key] as never) as never; + + if (isObservableArray(curVal)) { + curVal.replace(newVal); + } else if (isObservableSet(curVal) || isObservableMap(curVal)) { + curVal.replace(newVal); + } else { + this[key] = newVal; + } + } + + // TODO: Switch to action-based saving instead saving stores by reaction + if (preferences?.updateChannel) { + this.dependencies.selectedUpdateChannel.setValue(preferences?.updateChannel as ReleaseChannel); + } + } + + toJSON(): UserStoreModel { + const preferences = object.fromEntries( + object.entries(this.dependencies.preferenceDescriptors) + .map(([key, { toStore }]) => [key, toStore(this[key] as never)]), + ) as UserPreferencesModel; + + return toJS({ + preferences: { + ...preferences, + updateChannel: this.dependencies.selectedUpdateChannel.value.get().id, + }, + }); + } +} diff --git a/packages/core/src/common/utils/__tests__/cluster-id-url-parsing.test.ts b/packages/core/src/common/utils/__tests__/cluster-id-url-parsing.test.ts new file mode 100644 index 0000000000..3a4ccf605b --- /dev/null +++ b/packages/core/src/common/utils/__tests__/cluster-id-url-parsing.test.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getClusterIdFromHost } from "../cluster-id-url-parsing"; + +describe("getClusterIdFromHost", () => { + const clusterFakeId = "fe540901-0bd6-4f6c-b472-bce1559d7c4a"; + + it("should return undefined for non cluster frame hosts", () => { + expect(getClusterIdFromHost("lens.app:45345")).toBeUndefined(); + }); + + it("should return ClusterId for cluster frame hosts", () => { + expect(getClusterIdFromHost(`${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + }); + + it("should return ClusterId for cluster frame hosts with additional subdomains", () => { + expect(getClusterIdFromHost(`abc.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.yz.${clusterFakeId}.lens.app:59110`)).toBe(clusterFakeId); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/convert-memory.test.ts b/packages/core/src/common/utils/__tests__/convert-memory.test.ts new file mode 100644 index 0000000000..c69f43a061 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/convert-memory.test.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { bytesToUnits, unitsToBytes } from "../convertMemory"; + +describe("unitsToBytes", () => { + it("without any units, just parse as float", () => { + expect(unitsToBytes("1234")).toBe(1234); + }); + + it("should parse with B suffix", () => { + expect(unitsToBytes("1234B")).toBe(1234); + }); + + it("should parse with Ki suffix", () => { + expect(unitsToBytes("1234Ki")).toBe(1234 * (1024)); + }); + + it("should parse with Ki suffix as the same as KiB", () => { + expect(unitsToBytes("1234Ki")).toBe(unitsToBytes("1234KiB")); + }); + + it("should parse with Mi suffix", () => { + expect(unitsToBytes("1234Mi")).toBe(1234 * (1024 ** 2)); + }); + + it("should parse with Mi suffix as the same as MiB", () => { + expect(unitsToBytes("1234Mi")).toBe(unitsToBytes("1234MiB")); + }); + + it("should parse with Gi suffix", () => { + expect(unitsToBytes("1234Gi")).toBe(1234 * (1024 ** 3)); + }); + + it("should parse with Gi suffix as the same as GiB", () => { + expect(unitsToBytes("1234Gi")).toBe(unitsToBytes("1234GiB")); + }); + + it("should parse with Ti suffix", () => { + expect(unitsToBytes("1234Ti")).toBe(1234 * (1024 ** 4)); + }); + + it("should parse with Ti suffix as the same as TiB", () => { + expect(unitsToBytes("1234Ti")).toBe(unitsToBytes("1234TiB")); + }); + + it("should parse with Pi suffix", () => { + expect(unitsToBytes("1234Pi")).toBe(1234 * (1024 ** 5)); + }); + + it("should parse with Pi suffix as the same as PiB", () => { + expect(unitsToBytes("1234Pi")).toBe(unitsToBytes("1234PiB")); + }); + + it("given unrelated data, return NaN", () => { + expect(unitsToBytes("I am not a number")).toBeNaN(); + }); + + it("given unrelated data, but has number, return that", () => { + expect(unitsToBytes("I am not a number, but this is 0.1")).toBe(0.1); + }); +}); + +describe("bytesToUnits", () => { + it("should return N/A for invalid bytes", () => { + expect(bytesToUnits(-1)).toBe("N/A"); + expect(bytesToUnits(Infinity)).toBe("N/A"); + expect(bytesToUnits(NaN)).toBe("N/A"); + }); + + it("given a number within the magnitude of 0..124, format with B", () => { + expect(bytesToUnits(100)).toBe("100.0B"); + }); + + it("given a number within the magnitude of 1024..1024^2, format with KiB", () => { + expect(bytesToUnits(1024)).toBe("1.0KiB"); + expect(bytesToUnits(2048)).toBe("2.0KiB"); + expect(bytesToUnits(1900)).toBe("1.9KiB"); + expect(bytesToUnits(50*1024 + 1)).toBe("50.0KiB"); + }); + + it("given a number within the magnitude of 1024^2..1024^3, format with MiB", () => { + expect(bytesToUnits(1024**2)).toBe("1.0MiB"); + expect(bytesToUnits(2048**2)).toBe("4.0MiB"); + expect(bytesToUnits(1900 * 1024)).toBe("1.9MiB"); + expect(bytesToUnits(50*(1024 ** 2) + 1)).toBe("50.0MiB"); + }); + + it("given a number within the magnitude of 1024^3..1024^4, format with GiB", () => { + expect(bytesToUnits(1024**3)).toBe("1.0GiB"); + expect(bytesToUnits(2048**3)).toBe("8.0GiB"); + expect(bytesToUnits(1900 * 1024 ** 2)).toBe("1.9GiB"); + expect(bytesToUnits(50*(1024 ** 3) + 1)).toBe("50.0GiB"); + }); + + it("given a number within the magnitude of 1024^4..1024^5, format with TiB", () => { + expect(bytesToUnits(1024**4)).toBe("1.0TiB"); + expect(bytesToUnits(2048**4)).toBe("16.0TiB"); + expect(bytesToUnits(1900 * 1024 ** 3)).toBe("1.9TiB"); + expect(bytesToUnits(50*(1024 ** 4) + 1)).toBe("50.0TiB"); + }); + + it("given a number within the magnitude of 1024^5..1024^6, format with PiB", () => { + expect(bytesToUnits(1024**5)).toBe("1.0PiB"); + expect(bytesToUnits(2048**5)).toBe("32.0PiB"); + expect(bytesToUnits(1900 * 1024 ** 4)).toBe("1.9PiB"); + expect(bytesToUnits(50*(1024 ** 5) + 1)).toBe("50.0PiB"); + }); +}); + +describe("bytesToUnits -> unitsToBytes", () => { + it("given an input, round trip to the same value, given enough precision", () => { + expect(unitsToBytes(bytesToUnits(123))).toBe(123); + expect(unitsToBytes(bytesToUnits(1024**0 + 1, { precision: 2 }))).toBe(1024**0 + 1); + expect(unitsToBytes(bytesToUnits(1024**1 + 2, { precision: 3 }))).toBe(1024**1 + 2); + expect(unitsToBytes(bytesToUnits(1024**2 + 3, { precision: 6 }))).toBe(1024**2 + 3); + expect(unitsToBytes(bytesToUnits(1024**3 + 4, { precision: 9 }))).toBe(1024**3 + 4); + expect(unitsToBytes(bytesToUnits(1024**4 + 5, { precision: 12 }))).toBe(1024**4 + 5); + expect(unitsToBytes(bytesToUnits(1024**5 + 6, { precision: 16 }))).toBe(1024**5 + 6); + expect(unitsToBytes(bytesToUnits(1024**6 + 7, { precision: 20 }))).toBe(1024**6 + 7); + }); + + it("given an invalid input, round trips to NaN", () => { + expect(unitsToBytes(bytesToUnits(-1))).toBeNaN(); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/formatDuration.test.ts b/packages/core/src/common/utils/__tests__/formatDuration.test.ts new file mode 100644 index 0000000000..3c42db9060 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/formatDuration.test.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import moment from "moment"; +import { formatDuration } from "../formatDuration"; + +const second = 1000; +const minute = 60 * second; +const hour = 60 * minute; +const day = 24 * hour; +const year = 365 * day; + +describe("human format durations", () => { + test("small duration should output something", () => { + expect(formatDuration(1)).toBe("0s"); + expect(formatDuration(3)).toBe("0s"); + }); + + test("returns seconds for duration under 1 min", () => { + const res = formatDuration(8 * second); + + expect(res).toBe("8s"); + }); + + test("zero duration should output something", () => { + expect(formatDuration(0)).toBe("0s"); + }); + + describe("when compact is true", () => { + + test("duration under 3 hours return minutes", () => { + const res = formatDuration(1 * hour + 35 * minute); + + expect(res).toBe("95m"); + }); + + test("duration under 8 hours return hours and minutes", () => { + const res = formatDuration(6 * hour + 15 * minute + 20 * second); + + expect(res).toBe("6h15m"); + }); + + test("duration under 48 hours return hours", () => { + const res = formatDuration(1 * day + 4 * hour + 15 * minute); + + expect(res).toBe("28h"); + }); + + test("duration under 2 years return days", () => { + const res = formatDuration(400 * day + 4 * hour + 15 * minute); + + expect(res).toBe("400d"); + }); + + test("durations less than 8 years returns years and days", () => { + const timeValue = new Date(2020, 0, 10, 12, 0, 0, 0).getTime() - new Date(2018, 0, 4, 12, 0, 0, 0).getTime(); + + const res = formatDuration(timeValue); + + expect(res).toBe("2y5d"); + }); + + test("durations more than 8 years returns years", () => { + const timeValue = Date.now() - new Date(moment().subtract(9, "years").subtract(5, "days").toDate()).getTime(); + + const res = formatDuration(timeValue); + + expect(res).toBe("9y"); + }); + + test("durations more than 8 years returns years", () => { + const res = formatDuration(10 * year + 25 * day); + + expect(res).toBe("10y"); + }); + test("durations shouldn't include zero magnitude parts", () => { + const zeroSeconds = formatDuration(8 * minute); + + expect(zeroSeconds).toBe("8m"); + + const zeroMinutes = formatDuration(8 * hour + 15 * minute); + + expect(zeroMinutes).toBe("8h"); + + const zeroHours = formatDuration(6 * day + 2 * minute); + + expect(zeroHours).toBe("6d"); + + }); + }); + +}); diff --git a/packages/core/src/common/utils/__tests__/hash-set.test.ts b/packages/core/src/common/utils/__tests__/hash-set.test.ts new file mode 100644 index 0000000000..4e78a1ca4c --- /dev/null +++ b/packages/core/src/common/utils/__tests__/hash-set.test.ts @@ -0,0 +1,512 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { HashSet, ObservableHashSet } from "../hash-set"; + +describe("ObservableHashSet", () => { + it("should not throw on creation", () => { + expect(() => new ObservableHashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError(); + }); + + it("should be initializable", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + }); + + it("has should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("forEach should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + let a = 1; + + res.forEach((item) => { + expect(item.a).toEqual(a++); + }); + }); + + it("delete should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.delete({ a: 1 })).toBe(true); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 5 })).toBe(false); + expect(res.delete({ a: 5 })).toBe(false); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("toggle should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.toggle({ a: 1 }); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 6 })).toBe(false); + res.toggle({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 6 })).toBe(false); + res.add({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should treat the hash to be the same as equality", () => { + const res = new ObservableHashSet([ + { a: 1, foobar: "hello" }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.add({ a: 1, foobar: "goodbye" }); + expect(res.has({ a: 1 })).toBe(true); + }); + + it("clear should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.clear(); + expect(res.size).toBe(0); + }); + + it("replace should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.replace([{ a: 13 }]); + expect(res.size).toBe(1); + expect(res.has({ a: 1 })).toBe(false); + expect(res.has({ a: 13 })).toBe(true); + }); + + it("toJSON should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.toJSON()).toStrictEqual([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ]); + }); + + it("values should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.values(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("keys should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.keys(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("entries should work as expected", () => { + const res = new ObservableHashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.entries(); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 1 }, { a: 1 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 2 }, { a: 2 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 3 }, { a: 3 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 4 }, { a: 4 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); +}); + +describe("HashSet", () => { + it("should not throw on creation", () => { + expect(() => new HashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError(); + }); + + it("should be initializable", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + }); + + it("has should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("forEach should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + let a = 1; + + res.forEach((item) => { + expect(item.a).toEqual(a++); + }); + }); + + it("delete should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + expect(res.delete({ a: 1 })).toBe(true); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 5 })).toBe(false); + expect(res.delete({ a: 5 })).toBe(false); + expect(res.has({ a: 5 })).toBe(false); + }); + + it("toggle should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.toggle({ a: 1 }); + expect(res.has({ a: 1 })).toBe(false); + + expect(res.has({ a: 6 })).toBe(false); + res.toggle({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 6 })).toBe(false); + res.add({ a: 6 }); + expect(res.has({ a: 6 })).toBe(true); + }); + + it("add should treat the hash to be the same as equality", () => { + const res = new HashSet([ + { a: 1, foobar: "hello" }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.has({ a: 1 })).toBe(true); + res.add({ a: 1, foobar: "goodbye" }); + expect(res.has({ a: 1 })).toBe(true); + }); + + it("clear should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.clear(); + expect(res.size).toBe(0); + }); + + it("replace should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.size).toBe(4); + res.replace([{ a: 13 }]); + expect(res.size).toBe(1); + expect(res.has({ a: 1 })).toBe(false); + expect(res.has({ a: 13 })).toBe(true); + }); + + it("toJSON should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + + expect(res.toJSON()).toStrictEqual([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ]); + }); + + it("values should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.values(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("keys should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.keys(); + + expect(iter.next()).toStrictEqual({ + value: { a: 1 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 2 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 3 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: { a: 4 }, + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); + + it("entries should work as expected", () => { + const res = new HashSet([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + { a: 4 }, + ], item => item.a.toString()); + const iter = res.entries(); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 1 }, { a: 1 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 2 }, { a: 2 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 3 }, { a: 3 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: [{ a: 4 }, { a: 4 }], + done: false, + }); + + expect(iter.next()).toStrictEqual({ + value: undefined, + done: true, + }); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/iter.test.ts b/packages/core/src/common/utils/__tests__/iter.test.ts new file mode 100644 index 0000000000..e41894e662 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/iter.test.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { join, nth, reduce } from "../iter"; + +describe("iter", () => { + describe("reduce", () => { + it("can reduce a value", () => { + expect(reduce([1, 2, 3], (acc: number[], current: number) => [current, ...acc], [0])).toEqual([3, 2, 1, 0]); + }); + + it("can reduce an empty iterable", () => { + expect(reduce([], (acc: number[], current: number) => [acc[0] + current], [])).toEqual([]); + }); + }); + + describe("join", () => { + it("should not prefix the output by the seperator", () => { + expect(join(["a", "b", "c"].values(), " ")).toBe("a b c"); + }); + + it("should return empty string if iterator is empty", () => { + expect(join([].values(), " ")).toBe(""); + }); + + it("should return just first entry if iterator is of size 1", () => { + expect(join(["d"].values(), " ")).toBe("d"); + }); + }); + + describe("nth", () => { + it("should return undefined past the end of the iterator", () => { + expect(nth(["a"], 123)).toBeUndefined(); + }); + + it("should by 0-indexing the index", () => { + expect(nth(["a", "b"], 0)).toBe("a"); + }); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/n-fircate.test.ts b/packages/core/src/common/utils/__tests__/n-fircate.test.ts new file mode 100644 index 0000000000..7e6f84a095 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/n-fircate.test.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { nFircate } from "../n-fircate"; + +describe("nFircate", () => { + it("should produce an empty array if no parts are provided", () => { + expect(nFircate([{ a: 1 }, { a: 2 }], "a", []).length).toBe(0); + }); + + it("should ignore non-matching parts", () => { + const res = nFircate([{ a: 1 }, { a: 2 }], "a", [1]); + + expect(res.length).toBe(1); + expect(res[0].length).toBe(1); + }); + + it("should include all matching parts in each type", () => { + const res = nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2]); + + expect(res.length).toBe(2); + expect(res[0].length).toBe(2); + expect(res[0][0].b).toBe("a"); + expect(res[0][1].b).toBe("c"); + expect(res[1].length).toBe(1); + expect(res[1][0].b).toBe("b"); + }); + + it("should throw a type error if the same part is provided more than once", () => { + try { + nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2, 1]); + fail("Expected error"); + } catch (error) { + expect(error).toBeInstanceOf(TypeError); + } + }); +}); diff --git a/packages/core/src/common/utils/__tests__/paths.test.ts b/packages/core/src/common/utils/__tests__/paths.test.ts new file mode 100644 index 0000000000..5a52843432 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/paths.test.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainer } from "@ogre-tools/injectable"; +import path from "path"; +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import getAbsolutePathInjectable from "../../path/get-absolute-path.injectable"; +import getDirnameOfPathInjectable from "../../path/get-dirname.injectable"; +import type { IsLogicalChildPath } from "../../path/is-logical-child-path.injectable"; +import isLogicalChildPathInjectable from "../../path/is-logical-child-path.injectable"; + +describe("isLogicalChildPath", () => { + let di: DiContainer; + let isLogicalChildPath: IsLogicalChildPath; + + beforeEach(() => { + di = getDiForUnitTesting(); + }); + + describe("when using win32 paths", () => { + beforeEach(() => { + di.override(getAbsolutePathInjectable, () => path.win32.resolve); + di.override(getDirnameOfPathInjectable, () => path.win32.dirname); + isLogicalChildPath = di.inject(isLogicalChildPathInjectable); + }); + + it.each([ + { + parentPath: "C:\\Foo", + testPath: "C:\\Foo\\Bar", + expected: true, + }, + { + parentPath: "C:\\Foo", + testPath: "C:\\Bar", + expected: false, + }, + { + parentPath: "C:\\Foo", + testPath: "C:/Bar", + expected: false, + }, + { + parentPath: "C:\\Foo", + testPath: "C:/Foo/Bar", + expected: true, + }, + { + parentPath: "C:\\Foo", + testPath: "D:\\Foo\\Bar", + expected: false, + }, + ])("test %#", (testData) => { + expect(isLogicalChildPath(testData.parentPath, testData.testPath)).toBe(testData.expected); + }); + }); + + describe("when using posix paths", () => { + beforeEach(() => { + di.override(getAbsolutePathInjectable, () => path.posix.resolve); + di.override(getDirnameOfPathInjectable, () => path.posix.dirname); + isLogicalChildPath = di.inject(isLogicalChildPathInjectable); + }); + + it.each([ + { + parentPath: "/foo", + testPath: "/foo", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/bar", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/foobar", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/foo/bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/../bar", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/foo/./bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/.bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/..bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/...bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/..\\.bar", + expected: true, + }, + { + parentPath: "/bar/../foo", + testPath: "/foo/bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/\\bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "./bar", + expected: false, + }, + ])("test %#", (testData) => { + expect(isLogicalChildPath(testData.parentPath, testData.testPath)).toBe(testData.expected); + }); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/splitArray.test.ts b/packages/core/src/common/utils/__tests__/splitArray.test.ts new file mode 100644 index 0000000000..038d4731d8 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/splitArray.test.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { bifurcateArray, splitArray } from "../splitArray"; + +describe("split array on element tests", () => { + it("empty array", () => { + expect(splitArray([], 10)).toStrictEqual([[], [], false]); + }); + + it("one element, not in array", () => { + expect(splitArray([1], 10)).toStrictEqual([[1], [], false]); + }); + + it("ten elements, not in array", () => { + expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 10)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [], false]); + }); + + it("one elements, in array", () => { + expect(splitArray([1], 1)).toStrictEqual([[], [], true]); + }); + + it("ten elements, in front array", () => { + expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0)).toStrictEqual([[], [1, 2, 3, 4, 5, 6, 7, 8, 9], true]); + }); + + it("ten elements, in middle array", () => { + expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 4)).toStrictEqual([[0, 1, 2, 3], [5, 6, 7, 8, 9], true]); + }); + + it("ten elements, in end array", () => { + expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8], [], true]); + }); +}); + +describe("bifurcateArray", () => { + it("should return tuple of empty arrays from empty array", () => { + const [left, right] = bifurcateArray([], () => true); + + expect(left).toStrictEqual([]); + expect(right).toStrictEqual([]); + }); + + it("should return all true condition returning items in the right array", () => { + const [left, right] = bifurcateArray([1, 2, 3], () => true); + + expect(left).toStrictEqual([]); + expect(right).toStrictEqual([1, 2, 3]); + }); + + it("should return all false condition returning items in the right array", () => { + const [left, right] = bifurcateArray([1, 2, 3], () => false); + + expect(left).toStrictEqual([1, 2, 3]); + expect(right).toStrictEqual([]); + }); + + it("should split array as specified", () => { + const [left, right] = bifurcateArray([1, 2, 3], (i) => Boolean(i % 2)); + + expect(left).toStrictEqual([2]); + expect(right).toStrictEqual([1, 3]); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/toJS.test.ts b/packages/core/src/common/utils/__tests__/toJS.test.ts new file mode 100644 index 0000000000..1704ed7e25 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/toJS.test.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { isObservable, observable } from "mobx"; +import { toJS } from "../toJS"; + +describe("utils/toJS(data: any)", () => { + const y = { y: 2 }; + + const data = observable({ x: 1, y }, {}, { + deep: false, // this will keep ref to "y" + }); + const data2 = { + x: 1, // partially observable + y: observable(y), + }; + + test("converts mobx-observable to corresponding js struct with links preserving", () => { + expect(toJS(data).y).toBe(y); + expect(isObservable(toJS(data).y)).toBeFalsy(); + }); + + test("converts partially observable js struct", () => { + expect(toJS(data2).y).not.toBe(y); + expect(toJS(data2).y).toEqual(y); + expect(isObservable(toJS(data2).y)).toBeFalsy(); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/tuple.test.ts b/packages/core/src/common/utils/__tests__/tuple.test.ts new file mode 100644 index 0000000000..73a4ae1e25 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/tuple.test.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { tuple } from "../../utils"; + +describe("tuple tests", () => { + describe("zip()", () => { + it("should yield 0 times and return 1 tuple of empty arrays when given empty array", () => { + expect(tuple.zip([]).next()).toEqual({ + done: true, + value: [[]], + }); + }); + + it("should yield 1 times and return 2 tuple of empty arrays when given one element array tuples", () => { + const i = tuple.zip([1], [2]); + + expect(i.next()).toEqual({ + done: false, + value: [1, 2], + }); + expect(i.next()).toEqual({ + done: true, + value: [[], []], + }); + }); + + it("should yield 1 times and return 2 tuple of partial arrays when given one element array tuples", () => { + const i = tuple.zip([1], [2, 3]); + + expect(i.next()).toEqual({ + done: false, + value: [1, 2], + }); + expect(i.next()).toEqual({ + done: true, + value: [[], [3]], + }); + }); + }); +}); diff --git a/packages/core/src/common/utils/__tests__/union-env-path.test.ts b/packages/core/src/common/utils/__tests__/union-env-path.test.ts new file mode 100644 index 0000000000..ff8ca916d2 --- /dev/null +++ b/packages/core/src/common/utils/__tests__/union-env-path.test.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import { unionPATHs } from "../union-env-path"; + +describe("unionPATHs", () => { + it("return the same path if given only one with no double delimiters", () => { + expect(unionPATHs(`/bin/bar${path.delimiter}/usr/bin`)).toBe(`/bin/bar${path.delimiter}/usr/bin`); + }); + + it("return equivalent path if given only one with no double delimiters", () => { + expect(unionPATHs(`/bin/bar${path.delimiter}${path.delimiter}/usr/bin`)).toBe(`/bin/bar${path.delimiter}/usr/bin`); + }); + + it("should remove duplicate entries, appending non duplicates in order received", () => { + expect(unionPATHs( + `/bin/bar${path.delimiter}/usr/bin`, + `/bin/bar${path.delimiter}/usr/lens/bat`, + )).toBe(`/bin/bar${path.delimiter}/usr/bin${path.delimiter}/usr/lens/bat`); + }); + + it("should remove duplicate entries, appending non duplicates in order received, 3", () => { + expect(unionPATHs( + `/bin/bar${path.delimiter}/usr/bin`, + `/bin/bar${path.delimiter}/usr/lens/bat`, + `/usr/local/lens${path.delimiter}/usr/bin`, + )).toBe(`/bin/bar${path.delimiter}/usr/bin${path.delimiter}/usr/lens/bat${path.delimiter}/usr/local/lens`); + }); +}); diff --git a/packages/core/src/common/utils/abort-controller.ts b/packages/core/src/common/utils/abort-controller.ts new file mode 100644 index 0000000000..b062fce487 --- /dev/null +++ b/packages/core/src/common/utils/abort-controller.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import AbortController from "abort-controller"; + +/** + * This is like an `AbortController` but will also abort if the parent aborts, + * but won't make the parent abort if this aborts (single direction) + */ +export class WrappedAbortController extends AbortController { + constructor(parent?: AbortController | undefined) { + super(); + + parent?.signal.addEventListener("abort", () => { + this.abort(); + }); + } +} + +export function setTimeoutFor(controller: AbortController, timeout: number): void { + const handle = setTimeout(() => controller.abort(), timeout); + + controller.signal.addEventListener("abort", () => clearTimeout(handle)); +} diff --git a/packages/core/src/common/utils/add-separator/add-separator.test.ts b/packages/core/src/common/utils/add-separator/add-separator.test.ts new file mode 100644 index 0000000000..8a45b45739 --- /dev/null +++ b/packages/core/src/common/utils/add-separator/add-separator.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { addSeparator } from "./add-separator"; + +describe("add-separator", () => { + it("given multiple items, adds separators", () => { + const items = ["first", "second", "third"]; + + const actual = addSeparator((left, right) => `separator-between-${left}-and-${right}`, items); + + expect(actual).toEqual([ + "first", + "separator-between-first-and-second", + "second", + "separator-between-second-and-third", + "third", + ]); + }); + + it("given multiple items including falsy ones, adds separators", () => { + const items = [false, undefined, null, NaN]; + + const actual = addSeparator((left, right) => `separator-between-${left}-and-${right}`, items); + + expect(actual).toEqual([ + false, + "separator-between-false-and-undefined", + undefined, + "separator-between-undefined-and-null", + null, + "separator-between-null-and-NaN", + NaN, + ]); + }); + + it("given no items, does not add separator", () => { + const items: any[] = []; + + const actual = addSeparator(() => "separator", items); + + expect(actual).toEqual([]); + }); + + it("given one item, does not add separator", () => { + const items = ["first"]; + + const actual = addSeparator(() => "separator", items); + + expect(actual).toEqual(["first"]); + }); +}); diff --git a/packages/core/src/common/utils/add-separator/add-separator.ts b/packages/core/src/common/utils/add-separator/add-separator.ts new file mode 100644 index 0000000000..6fd6b14642 --- /dev/null +++ b/packages/core/src/common/utils/add-separator/add-separator.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type GetSeparator = (left: Item, right: Item) => Separator; + +export const addSeparator = ( + getSeparator: GetSeparator, + items: Item[], +) => items.flatMap(toSeparatedTupleUsing(getSeparator)); + +const toSeparatedTupleUsing = + (getSeparator: GetSeparator) => + (leftItem: Item, index: number, arr: Item[]) => { + const itemIsLast = arr.length === index + 1; + + if (itemIsLast) { + return [leftItem]; + } + + const rightItem = arr[index + 1]; + const separator = getSeparator(leftItem, rightItem); + + return [leftItem, separator]; + }; diff --git a/packages/core/src/common/utils/array.ts b/packages/core/src/common/utils/array.ts new file mode 100644 index 0000000000..b9071c33ec --- /dev/null +++ b/packages/core/src/common/utils/array.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * A inference typed version of `Array(length).fill(value)` + * @param length The number of entries + * @param value The value of each of the indices + */ +export function filled(length: number, value: T): T[] { + return Array(length).fill(value); +} diff --git a/packages/core/src/common/utils/async-result.ts b/packages/core/src/common/utils/async-result.ts new file mode 100644 index 0000000000..69927f3275 --- /dev/null +++ b/packages/core/src/common/utils/async-result.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export type AsyncResult = + | ( + Response extends void + ? { callWasSuccessful: true; response?: undefined } + : { callWasSuccessful: true; response: Response } + ) + | { callWasSuccessful: false; error: Error }; diff --git a/packages/core/src/common/utils/autobind.ts b/packages/core/src/common/utils/autobind.ts new file mode 100644 index 0000000000..49feb435e3 --- /dev/null +++ b/packages/core/src/common/utils/autobind.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Options } from "auto-bind"; +import autoBindClass from "auto-bind"; +import autoBindReactClass from "auto-bind/react"; +import React from "react"; + +// Automatically bind methods to their class instance +export function autoBind(obj: T, opts?: Options): T { + if (obj instanceof React.Component) { + return autoBindReactClass(obj, opts); + } + + return autoBindClass(obj, opts); +} diff --git a/packages/core/src/common/utils/base64.ts b/packages/core/src/common/utils/base64.ts new file mode 100755 index 0000000000..c55a5de6a7 --- /dev/null +++ b/packages/core/src/common/utils/base64.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Encode/decode utf-8 base64 string +import * as Base64 from "crypto-js/enc-base64"; +import * as Utf8 from "crypto-js/enc-utf8"; + +/** + * Computes utf-8 from base64 + * @param data A Base64 encoded string + * @returns The original utf-8 string + */ +export function decode(data: string): string { + return Base64.parse(data).toString(Utf8); +} + +/** + * Computes base64 from utf-8 + * @param data A normal string + * @returns A base64 encoded version + */ +export function encode(data: string): string { + return Utf8.parse(data).toString(Base64); +} diff --git a/packages/core/src/common/utils/binary-name.injectable.ts b/packages/core/src/common/utils/binary-name.injectable.ts new file mode 100644 index 0000000000..d240b3aae0 --- /dev/null +++ b/packages/core/src/common/utils/binary-name.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import normalizedPlatformInjectable from "../vars/normalized-platform.injectable"; + +const binaryNameInjectable = getInjectable({ + id: "binary-name", + instantiate: (di, binaryName) => { + const normalizedPlatform = di.inject(normalizedPlatformInjectable); + + if (normalizedPlatform === "windows") { + return `${binaryName}.exe`; + } + + return binaryName; + }, + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, binaryName: string) => binaryName, + }), +}); + +export default binaryNameInjectable; diff --git a/packages/core/src/common/utils/buildUrl.ts b/packages/core/src/common/utils/buildUrl.ts new file mode 100644 index 0000000000..82d114cd08 --- /dev/null +++ b/packages/core/src/common/utils/buildUrl.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { compile } from "path-to-regexp"; +import type { RouteProps } from "react-router"; +import { isDefined } from "./type-narrowing"; + +export interface UrlRouteProps extends RouteProps { + path: string; +} + +export interface URLParams

{ + params?: P; + query?: Q; + fragment?: string; +} + +export function buildURL

(path: string, { params, query, fragment }: URLParams = {}) { + const pathBuilder = compile(String(path)); + + const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : ""; + const parts = [ + pathBuilder(params), + queryParams && `?${queryParams}`, + fragment && `#${fragment}`, + ]; + + return parts.filter(isDefined).join(""); +} + +export function buildURLPositional

(path: string) { + return function (params?: P, query?: Q, fragment?: string): string { + return buildURL(path, { params, query, fragment }); + }; +} + +export type UrlParamsFor = + Pathname extends `${string}/:${infer A}?/${infer Tail}` + ? Partial> & UrlParamsFor<`/${Tail}`> + : Pathname extends `${string}/:${infer A}/${infer Tail}` + ? Record & UrlParamsFor<`/${Tail}`> + : Pathname extends `${string}/:${infer A}?` + ? Partial> + : Pathname extends `${string}/:${infer A}` + ? Record + : {}; + +export interface UrlBuilder { + compile(params: UrlParamsFor, query?: object, fragment?: string): string; +} + +export function urlBuilderFor(pathname: Pathname): UrlBuilder { + return { + compile: buildURLPositional(pathname), + }; +} diff --git a/packages/core/src/common/utils/bundled-binary-path.injectable.ts b/packages/core/src/common/utils/bundled-binary-path.injectable.ts new file mode 100644 index 0000000000..42ecbca0d9 --- /dev/null +++ b/packages/core/src/common/utils/bundled-binary-path.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; +import baseBundledBinariesDirectoryInjectable from "../vars/base-bundled-binaries-dir.injectable"; +import binaryNameInjectable from "./binary-name.injectable"; + +const bundledBinaryPathInjectable = getInjectable({ + id: "bundled-binary-path", + instantiate: (di, name) => { + const joinPaths = di.inject(joinPathsInjectable); + const binaryName = di.inject(binaryNameInjectable, name); + const baseBundledBinariesDirectory = di.inject(baseBundledBinariesDirectoryInjectable); + + return joinPaths(baseBundledBinariesDirectory, binaryName); + }, + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, binaryName: string) => binaryName, + }), +}); + +export default bundledBinaryPathInjectable; diff --git a/packages/core/src/common/utils/camelCase.ts b/packages/core/src/common/utils/camelCase.ts new file mode 100644 index 0000000000..a37e4c7f5c --- /dev/null +++ b/packages/core/src/common/utils/camelCase.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Convert object's keys to camelCase format +import { camelCase } from "lodash"; +import type { SingleOrMany } from "./types"; +import { isObject, isString } from "./type-narrowing"; +import * as object from "./objects"; + +export function toCamelCase[]>(obj: T): T; +export function toCamelCase>(obj: T): T; + +export function toCamelCase(obj: SingleOrMany | unknown>): SingleOrMany | unknown> { + if (Array.isArray(obj)) { + return obj.map(toCamelCase); + } + + if (isObject(obj)) { + return object.fromEntries( + object.entries(obj) + .filter((pair): pair is [string, unknown] => isString(pair[0])) + .map(([key, value]) => [camelCase(key), isObject(value) ? toCamelCase(value) : value]), + ); + } + + return obj; +} diff --git a/packages/core/src/common/utils/channel/channel-injection-token.ts b/packages/core/src/common/utils/channel/channel-injection-token.ts new file mode 100644 index 0000000000..6006290f89 --- /dev/null +++ b/packages/core/src/common/utils/channel/channel-injection-token.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +export interface Channel { + id: string; + _messageTemplate?: MessageTemplate; + _returnTemplate?: ReturnTemplate; +} + diff --git a/packages/core/src/common/utils/channel/channel.test.ts b/packages/core/src/common/utils/channel/channel.test.ts new file mode 100644 index 0000000000..9a361b6770 --- /dev/null +++ b/packages/core/src/common/utils/channel/channel.test.ts @@ -0,0 +1,231 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { SendMessageToChannel } from "./message-to-channel-injection-token"; +import { sendMessageToChannelInjectionToken } from "./message-to-channel-injection-token"; +import type { ApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import type { LensWindow } from "../../../main/start-main-application/lens-window/application-window/create-lens-window.injectable"; +import type { MessageChannel } from "./message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; +import type { RequestFromChannel } from "./request-from-channel-injection-token"; +import { requestFromChannelInjectionToken } from "./request-from-channel-injection-token"; +import type { RequestChannel } from "./request-channel-listener-injection-token"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; +import { runInAction } from "mobx"; +import type { RequestChannelHandler } from "../../../main/utils/channel/channel-listeners/listener-tokens"; +import { getRequestChannelListenerInjectable } from "../../../main/utils/channel/channel-listeners/listener-tokens"; + +type TestMessageChannel = MessageChannel; +type TestRequestChannel = RequestChannel; + +describe("channel", () => { + describe("messaging from main to renderer, given listener for channel in a window and application has started", () => { + let messageListenerInWindowMock: jest.Mock; + let mainDi: DiContainer; + let messageToChannel: SendMessageToChannel; + let builder: ApplicationBuilder; + + beforeEach(async () => { + builder = getApplicationBuilder(); + + messageListenerInWindowMock = jest.fn(); + + const testChannelListenerInTestWindowInjectable = getInjectable({ + id: "test-channel-listener-in-test-window", + + instantiate: () => ({ + channel: testMessageChannel, + handler: messageListenerInWindowMock, + }), + + injectionToken: messageChannelListenerInjectionToken, + }); + + builder.beforeWindowStart((windowDi) => { + runInAction(() => { + windowDi.register(testChannelListenerInTestWindowInjectable); + }); + }); + + mainDi = builder.mainDi; + + await builder.startHidden(); + + messageToChannel = mainDi.inject(sendMessageToChannelInjectionToken); + }); + + describe("given window is started", () => { + let someWindowFake: LensWindow; + + beforeEach(async () => { + someWindowFake = builder.applicationWindow.create("some-window"); + + await someWindowFake.start(); + }); + + it("when sending message, triggers listener in window", () => { + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock).toHaveBeenCalledWith("some-message"); + }); + + it("given window is hidden, when sending message, does not trigger listener in window", () => { + someWindowFake.close(); + + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock).not.toHaveBeenCalled(); + }); + }); + + it("given multiple started windows, when sending message, triggers listeners in all windows", async () => { + const someWindowFake = builder.applicationWindow.create("some-window"); + const someOtherWindowFake = builder.applicationWindow.create("some-other-window"); + + await someWindowFake.start(); + await someOtherWindowFake.start(); + + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock.mock.calls).toEqual([ + ["some-message"], + ["some-message"], + ]); + }); + }); + + describe("messaging from renderer to main, given listener for channel in a main and application has started", () => { + let messageListenerInMainMock: jest.Mock; + let messageToChannel: SendMessageToChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + messageListenerInMainMock = jest.fn(); + + const testChannelListenerInMainInjectable = getInjectable({ + id: "test-channel-listener-in-main", + + instantiate: () => ({ + channel: testMessageChannel, + handler: messageListenerInMainMock, + }), + + injectionToken: messageChannelListenerInjectionToken, + }); + + applicationBuilder.beforeApplicationStart((mainDi) => { + runInAction(() => { + mainDi.register(testChannelListenerInMainInjectable); + }); + }); + + await applicationBuilder.render(); + + const windowDi = applicationBuilder.applicationWindow.only.di; + + messageToChannel = windowDi.inject(sendMessageToChannelInjectionToken); + }); + + it("when sending message, triggers listener in main", () => { + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInMainMock).toHaveBeenCalledWith("some-message"); + }); + }); + + describe("requesting from main in renderer, given listener for channel in a main and application has started", () => { + let requestListenerInMainMock: AsyncFnMock>; + let requestFromChannel: RequestFromChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + requestListenerInMainMock = asyncFn(); + + const testChannelListenerInMainInjectable = getRequestChannelListenerInjectable({ + channel: testRequestChannel, + handler: () => requestListenerInMainMock, + }); + + applicationBuilder.beforeApplicationStart((mainDi) => { + runInAction(() => { + mainDi.register(testChannelListenerInMainInjectable); + }); + }); + + await applicationBuilder.render(); + + const windowDi = applicationBuilder.applicationWindow.only.di; + + requestFromChannel = windowDi.inject( + requestFromChannelInjectionToken, + ); + }); + + describe("when requesting from channel", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = requestFromChannel(testRequestChannel, "some-request"); + }); + + it("triggers listener in main", () => { + expect(requestListenerInMainMock).toHaveBeenCalledWith("some-request"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when main resolves with response, resolves with response", async () => { + await requestListenerInMainMock.resolve("some-response"); + + const actual = await actualPromise; + + expect(actual).toBe("some-response"); + }); + }); + }); + + it("when registering multiple handlers for the same channel, throws", async () => { + const applicationBuilder = getApplicationBuilder(); + + const testChannelListenerInMainInjectable = getRequestChannelListenerInjectable({ + channel: testRequestChannel, + handler: () => () => "some-value", + }); + const testChannelListenerInMain2Injectable = getRequestChannelListenerInjectable({ + channel: testRequestChannel, + handler: () => () => "some-other-value", + }); + + testChannelListenerInMain2Injectable.id += "2"; + + applicationBuilder.beforeApplicationStart((mainDi) => { + runInAction(() => { + mainDi.register(testChannelListenerInMainInjectable); + mainDi.register(testChannelListenerInMain2Injectable); + }); + }); + + await expect(applicationBuilder.render()).rejects.toThrow('Tried to register a multiple channel handlers for "some-request-channel-id", only one handler is supported for a request channel.'); + }); +}); + +const testMessageChannel: TestMessageChannel = { + id: "some-message-channel-id", +}; + +const testRequestChannel: TestRequestChannel = { + id: "some-request-channel-id", +}; + diff --git a/packages/core/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts b/packages/core/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..34f62d51d5 --- /dev/null +++ b/packages/core/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Disposer } from "../disposer"; +import type { MessageChannel, MessageChannelListener } from "./message-channel-listener-injection-token"; + +export type EnlistMessageChannelListener = (listener: MessageChannelListener>) => Disposer; + +export const enlistMessageChannelListenerInjectionToken = getInjectionToken({ + id: "enlist-message-channel-listener", +}); diff --git a/packages/core/src/common/utils/channel/get-request-channel.ts b/packages/core/src/common/utils/channel/get-request-channel.ts new file mode 100644 index 0000000000..4dc5b4914e --- /dev/null +++ b/packages/core/src/common/utils/channel/get-request-channel.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RequestChannel } from "./request-channel-listener-injection-token"; + +export const getRequestChannel = (id: string): RequestChannel => ({ + id, +}); diff --git a/packages/core/src/common/utils/channel/listening-on-message-channels.injectable.ts b/packages/core/src/common/utils/channel/listening-on-message-channels.injectable.ts new file mode 100644 index 0000000000..afe0c08f24 --- /dev/null +++ b/packages/core/src/common/utils/channel/listening-on-message-channels.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getStartableStoppable } from "../get-startable-stoppable"; +import { disposer } from "../index"; +import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "./enlist-message-channel-listener-injection-token"; + +const listeningOnMessageChannelsInjectable = getInjectable({ + id: "listening-on-message-channels", + + instantiate: (di) => { + const enlistMessageChannelListener = di.inject(enlistMessageChannelListenerInjectionToken); + const messageChannelListeners = di.injectMany(messageChannelListenerInjectionToken); + + return getStartableStoppable("listening-on-channels", () => ( + disposer(messageChannelListeners.map(enlistMessageChannelListener)) + )); + }, +}); + + +export default listeningOnMessageChannelsInjectable; diff --git a/packages/core/src/common/utils/channel/message-channel-listener-injection-token.ts b/packages/core/src/common/utils/channel/message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..5bfc45a82d --- /dev/null +++ b/packages/core/src/common/utils/channel/message-channel-listener-injection-token.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainerForInjection } from "@ogre-tools/injectable"; +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; + +export interface MessageChannel { + id: string; + _messageSignature?: Message; // only used to mark `Message` as used +} + +export type MessageChannelHandler = Channel extends MessageChannel + ? (message: Message) => void + : never; + +export interface MessageChannelListener { + channel: Channel; + handler: MessageChannelHandler; +} + +export const messageChannelListenerInjectionToken = getInjectionToken>>( + { + id: "message-channel-listener", + }, +); + +export interface GetMessageChannelListenerInfo< + Channel extends MessageChannel, + Message, +> { + id: string; + channel: Channel; + handler: (di: DiContainerForInjection) => MessageChannelHandler; + causesSideEffects?: boolean; +} + +export function getMessageChannelListenerInjectable< + Channel extends MessageChannel, + Message, +>(info: GetMessageChannelListenerInfo) { + return getInjectable({ + id: `${info.channel.id}-listener-${info.id}`, + instantiate: (di) => ({ + channel: info.channel, + handler: info.handler(di), + }), + injectionToken: messageChannelListenerInjectionToken, + causesSideEffects: info.causesSideEffects, + }); +} diff --git a/packages/core/src/common/utils/channel/message-to-channel-injection-token.ts b/packages/core/src/common/utils/channel/message-to-channel-injection-token.ts new file mode 100644 index 0000000000..3ffd75f4f7 --- /dev/null +++ b/packages/core/src/common/utils/channel/message-to-channel-injection-token.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MessageChannel } from "./message-channel-listener-injection-token"; + +export interface SendMessageToChannel { + (channel: MessageChannel): void; + (channel: MessageChannel, message: Message): void; +} + +export type MessageChannelSender = Channel extends MessageChannel + ? () => void + : Channel extends MessageChannel + ? (message: Message) => void + : never; + +export const sendMessageToChannelInjectionToken = getInjectionToken({ + id: "send-message-to-message-channel", +}); diff --git a/packages/core/src/common/utils/channel/request-channel-listener-injection-token.ts b/packages/core/src/common/utils/channel/request-channel-listener-injection-token.ts new file mode 100644 index 0000000000..2f0b84a3cc --- /dev/null +++ b/packages/core/src/common/utils/channel/request-channel-listener-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface RequestChannel { + id: string; + _requestSignature?: Request; // used only to mark `Request` as "used" + _responseSignature?: Response; // used only to mark `Response` as "used" +} diff --git a/packages/core/src/common/utils/channel/request-from-channel-injection-token.ts b/packages/core/src/common/utils/channel/request-from-channel-injection-token.ts new file mode 100644 index 0000000000..14e925f190 --- /dev/null +++ b/packages/core/src/common/utils/channel/request-from-channel-injection-token.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { RequestChannel } from "./request-channel-listener-injection-token"; + +export interface RequestFromChannel { + (channel: RequestChannel, request: Request): Promise; + (channel: RequestChannel): Promise; +} + +export const requestFromChannelInjectionToken = getInjectionToken({ + id: "request-from-request-channel", +}); diff --git a/packages/core/src/common/utils/cluster-id-url-parsing.ts b/packages/core/src/common/utils/cluster-id-url-parsing.ts new file mode 100644 index 0000000000..393a791f09 --- /dev/null +++ b/packages/core/src/common/utils/cluster-id-url-parsing.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ClusterId } from "../cluster-types"; + +/** + * Grab the `ClusterId` out of a host that was generated by `getClusterFrameUrl`, or nothing + * @param host The host section of a URL + * @returns The `ClusterId` part of the host, or `undefined` + */ +export function getClusterIdFromHost(host: string): ClusterId | undefined { + // e.g host == "%clusterId.localhost:45345" + const subDomains = host.split(":")[0].split("."); + + return subDomains.slice(-3, -2)[0]; // ClusterId or undefined +} + +/** + * Get the OpenLens backend routing host for a given `ClusterId` + * @param clusterId The ID to put in front of the current host + * @returns a new URL host section + */ +export function getClusterFrameUrl(clusterId: ClusterId) { + return `//${clusterId}.${location.host}`; +} diff --git a/packages/core/src/common/utils/collection-functions.ts b/packages/core/src/common/utils/collection-functions.ts new file mode 100644 index 0000000000..5d6dc80548 --- /dev/null +++ b/packages/core/src/common/utils/collection-functions.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { runInAction } from "mobx"; +import { inspect } from "util"; +import { isDefined } from "./type-narrowing"; + +/** + * Get the value behind `key`. If it was not present, first insert `value` + * @param map The map to interact with + * @param key The key to insert into the map with + * @param value The value to optional add to the map + * @returns The value in the map + */ +export function getOrInsert(map: Map, key: K, value: V): V { + if (!map.has(key)) { + map.set(key, value); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; +} + +/** + * Updates map and returns the value that was just inserted + */ +export function put(map: Map, key: K, value: V): V { + map.set(key, value); + + return value; +} + +/** + * Like `getOrInsert` but specifically for when `V` is `Map` so that + * the typings are inferred correctly. + */ +export function getOrInsertMap(map: Map>, key: K): Map { + return getOrInsert(map, key, new Map()); +} + +/** + * Like `getOrInsert` but specifically for when `V` is `Set` so that + * the typings are inferred. + */ +export function getOrInsertSet(map: Map>, key: K): Set { + return getOrInsert(map, key, new Set()); +} + +/** + * A currying version of {@link getOrInsertSet} + */ +export function getOrInsertSetFor(map: Map>): (key: K) => Set { + return (key) => getOrInsertSet(map, key); +} + +/** + * Like `getOrInsert` but with delayed creation of the item. Which is useful + * if it is very expensive to create the initial value. + */ +export function getOrInsertWith(map: Map, key: K, builder: () => V): V; +export function getOrInsertWith(map: Map | WeakMap, key: K, builder: () => V): V; + +export function getOrInsertWith(map: Map | WeakMap, key: K, builder: () => V): V { + if (!map.has(key)) { + map.set(key, builder()); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; +} + +/** + * Like {@link getOrInsertWith} but the builder is async and will be awaited before inserting into the map + */ +export async function getOrInsertWithAsync(map: Map, key: K, asyncBuilder: () => Promise): Promise { + if (!map.has(key)) { + map.set(key, await asyncBuilder()); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; +} + +/** + * Set the value associated with `key` iff there was not a previous value + * @param map The map to interact with + * @throws if `key` already in map + * @returns `this` so that `strictSet` can be chained + */ +export function strictSet(map: Map, key: K, val: V): typeof map { + if (map.has(key)) { + throw new TypeError(`Map already contains key: ${inspect(key)}`); + } + + return map.set(key, val); +} + +/** + * Get the value associated with `key` + * @param map The map to interact with + * @throws if `key` did not a value associated with it + */ +export function strictGet(map: Map, key: K): V { + if (!map.has(key)) { + throw new TypeError(`Map does not contains key: ${inspect(key)}`); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; +} + +/** + * If `key` is in `set`, remove it otherwise add it. + * @param set The set to manipulate + * @param key The key to toggle the "is in"-ness of + */ +export function toggle(set: Set, key: K): void { + runInAction(() => { + // Returns true if value was already in Set; otherwise false. + if (!set.delete(key)) { + set.add(key); + } + }); +} + +/** + * A helper function to also check for defined-ness + */ +export function includes(src: T[], value: T | null | undefined): boolean { + return isDefined(value) && src.includes(value); +} diff --git a/packages/core/src/common/utils/composable-responsibilities/discriminable/discriminable.ts b/packages/core/src/common/utils/composable-responsibilities/discriminable/discriminable.ts new file mode 100644 index 0000000000..7976004ee6 --- /dev/null +++ b/packages/core/src/common/utils/composable-responsibilities/discriminable/discriminable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions +export interface Discriminable { readonly kind: T } + +// Note: this will fail at transpilation time, if all kinds are not instructed in switch/case. +// See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking +export const checkThatAllDiscriminablesAreExhausted = (value: T) => { + const _exhaustiveCheck: never = value; + + return new Error( + `Tried to exhaust discriminables, but no instructions were found for ${(_exhaustiveCheck as any).kind}`, + ); +}; diff --git a/packages/core/src/common/utils/composable-responsibilities/labelable/labelable.ts b/packages/core/src/common/utils/composable-responsibilities/labelable/labelable.ts new file mode 100644 index 0000000000..e0f2d98c21 --- /dev/null +++ b/packages/core/src/common/utils/composable-responsibilities/labelable/labelable.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export interface Labelable { + readonly label: string; +} diff --git a/packages/core/src/common/utils/composable-responsibilities/orderable/orderable.ts b/packages/core/src/common/utils/composable-responsibilities/orderable/orderable.ts new file mode 100644 index 0000000000..2b4d95b3f4 --- /dev/null +++ b/packages/core/src/common/utils/composable-responsibilities/orderable/orderable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { sortBy } from "lodash/fp"; + +export interface Orderable { + readonly orderNumber: number; +} + +export type MaybeOrderable = Orderable | object; + +export const orderByOrderNumber = (maybeOrderables: T[]) => + sortBy( + (orderable) => + "orderNumber" in orderable + ? orderable.orderNumber + : Number.MAX_SAFE_INTEGER, + maybeOrderables, + ); + +export const byOrderNumber = (left: T, right: T) => ( + left.orderNumber - right.orderNumber +); diff --git a/packages/core/src/common/utils/composable-responsibilities/showable/showable.ts b/packages/core/src/common/utils/composable-responsibilities/showable/showable.ts new file mode 100644 index 0000000000..ad8e2ed25b --- /dev/null +++ b/packages/core/src/common/utils/composable-responsibilities/showable/showable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { IComputedValue } from "mobx"; +import { isBoolean } from "../../type-narrowing"; + +export interface Showable { + readonly isShown: IComputedValue | boolean; +} + +export type MaybeShowable = Showable | object; + +export const isShown = (showable: MaybeShowable) => { + if (!("isShown" in showable)) { + return true; + } + + if (showable.isShown === undefined) { + return true; + } + + if (isBoolean(showable.isShown)) { + return showable.isShown; + } + + return showable.isShown.get(); +}; diff --git a/packages/core/src/common/utils/composite/composite-has-descendant/composite-has-descendant.test.ts b/packages/core/src/common/utils/composite/composite-has-descendant/composite-has-descendant.test.ts new file mode 100644 index 0000000000..0b7f07bf2d --- /dev/null +++ b/packages/core/src/common/utils/composite/composite-has-descendant/composite-has-descendant.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Composite } from "../get-composite/get-composite"; +import { compositeHasDescendant } from "./composite-has-descendant"; +import { getCompositeFor } from "../get-composite/get-composite"; + +describe("composite-has-descendant, given composite with children and grand children", () => { + let composite: Composite<{ id: string; parentId?: string }>; + + beforeEach(() => { + const items = [ + { id: "some-root-id", parentId: undefined }, + { id: "some-child-item", parentId: "some-root-id" }, + + { + id: "some-grand-child-item", + parentId: "some-child-item", + }, + ]; + + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + composite = getComposite(items); + }); + + it("has a child as descendant", () => { + const actual = compositeHasDescendant( + (referenceComposite) => referenceComposite.value.id === "some-child-item", + )(composite); + + expect(actual).toBe(true); + }); + + it("has a grand child as descendant", () => { + const actual = compositeHasDescendant( + (referenceComposite) => + referenceComposite.value.id === "some-grand-child-item", + )(composite); + + expect(actual).toBe(true); + }); + + it("does not have an unrelated descendant", () => { + const actual = compositeHasDescendant( + (referenceComposite) => + referenceComposite.value.id === "some-unknown-item", + )(composite); + + expect(actual).toBe(false); + }); +}); diff --git a/packages/core/src/common/utils/composite/composite-has-descendant/composite-has-descendant.ts b/packages/core/src/common/utils/composite/composite-has-descendant/composite-has-descendant.ts new file mode 100644 index 0000000000..030f79ad74 --- /dev/null +++ b/packages/core/src/common/utils/composite/composite-has-descendant/composite-has-descendant.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; + +const compositeHasDescendant = ( + predicate: (referenceComposite: Composite) => boolean, +) => { + const _compositeHasDescendant = (composite: Composite): boolean => + predicate(composite) || + !!composite.children.find((childComposite) => + _compositeHasDescendant(childComposite), + ); + + return _compositeHasDescendant; +}; + +export { compositeHasDescendant }; diff --git a/packages/core/src/common/utils/composite/find-composite/find-composite.test.ts b/packages/core/src/common/utils/composite/find-composite/find-composite.test.ts new file mode 100644 index 0000000000..5b4147070e --- /dev/null +++ b/packages/core/src/common/utils/composite/find-composite/find-composite.test.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; +import { findComposite } from "./find-composite"; +import { getCompositeFor } from "../get-composite/get-composite"; + +describe("find-composite", () => { + let composite: Composite<{ id: string; parentId?: string }>; + + beforeEach(() => { + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + composite = getComposite([ + { id: "some-root-id" }, + { id: "some-child-id", parentId: "some-root-id" }, + { id: "some-grandchild-id", parentId: "some-child-id" }, + { id: "some-other-grandchild-id", parentId: "some-child-id" }, + ]); + }); + + it("when finding root using path, does so", () => { + const actual = findComposite("some-root-id")(composite); + + expect(actual.id).toBe("some-root-id"); + }); + + it("when finding child using path, does so", () => { + const actual = findComposite("some-root-id", "some-child-id")(composite); + + expect(actual.id).toBe("some-child-id"); + }); + + it("when finding grandchild using path, does so", () => { + const actual = findComposite( + "some-root-id", + "some-child-id", + "some-grandchild-id", + )(composite); + + expect(actual.id).toBe("some-grandchild-id"); + }); + + it("when finding with non existing leaf-level path, throws", () => { + expect(() => { + findComposite( + "some-root-id", + "some-child-id", + "some-non-existing-grandchild-id", + )(composite); + }).toThrow(`Tried to find 'some-root-id -> some-child-id -> some-non-existing-grandchild-id' from a composite, but found nothing. + +Node 'some-root-id -> some-child-id' had only following children: +some-grandchild-id +some-other-grandchild-id`); + }); + + it("when finding with non-existing mid-level path, throws", () => { + expect(() => { + findComposite( + "some-root-id", + "some-non-existing-child-id", + "some-non-existing-grandchild-id", + )(composite); + }).toThrow(`Tried to find 'some-root-id -> some-non-existing-child-id -> some-non-existing-grandchild-id' from a composite, but found nothing. + +Node 'some-root-id' had only following children: +some-child-id`); + }); + + it("when finding with non-existing root-level path, throws", () => { + expect(() => { + findComposite( + "some-non-existing-root-id", + "some-non-existing-child-id", + "some-non-existing-grandchild-id", + )(composite); + }).toThrow(`Tried to find 'some-non-existing-root-id -> some-non-existing-child-id -> some-non-existing-grandchild-id' from a composite, but found nothing. + +Node 'some-root-id' had only following children: +some-child-id`); + }); +}); diff --git a/packages/core/src/common/utils/composite/find-composite/find-composite.ts b/packages/core/src/common/utils/composite/find-composite/find-composite.ts new file mode 100644 index 0000000000..71dcf5baa3 --- /dev/null +++ b/packages/core/src/common/utils/composite/find-composite/find-composite.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; + +const _findComposite = (currentLeftIds: string[], currentId: string, currentRightIds: string[], composite: Composite): Composite => { + const [nextId, ...nextRightIds] = currentRightIds; + const nextLeftIds = [...currentLeftIds, currentId]; + + if (currentRightIds.length === 0 && composite.id === currentId) { + return composite; + } + + const foundChildComposite = composite.children.find((child) => child.id === nextId); + + if (foundChildComposite) { + return _findComposite(nextLeftIds, nextId, nextRightIds, foundChildComposite); + } + + const fullPathString = [...currentLeftIds, currentId, ...currentRightIds].join(" -> "); + + throw new Error(`Tried to find '${fullPathString}' from a composite, but found nothing. + +Node '${[...currentLeftIds, composite.id].join(" -> ")}' had only following children: +${composite.children.map((child) => child.id).join("\n")}`); +}; + +export const findComposite = + (...path: string[]) => + (composite: Composite): Composite => { + const [currentId, ...rightIds] = path; + const leftIds: string[] = []; + + return _findComposite(leftIds, currentId, rightIds, composite); + }; diff --git a/packages/core/src/common/utils/composite/get-composite-normalization/get-composite-normalization.test.ts b/packages/core/src/common/utils/composite/get-composite-normalization/get-composite-normalization.test.ts new file mode 100644 index 0000000000..ac88973251 --- /dev/null +++ b/packages/core/src/common/utils/composite/get-composite-normalization/get-composite-normalization.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getCompositeNormalization } from "./get-composite-normalization"; +import { getCompositeFor } from "../get-composite/get-composite"; + + +describe("get-composite-normalization", () => { + it("given a composite, flattens it to paths and composites", () => { + const someRootItem = { + id: "some-root-id", + parentId: undefined, + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + }; + + const someNestedItem = { + id: "some-child-id", + parentId: "some-id", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + orderNumber?: number; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + const composite = getComposite(items); + + const actual = getCompositeNormalization(composite); + + expect(actual).toEqual([ + [["some-root-id"], expect.objectContaining({ value: someRootItem })], + + [["some-root-id", "some-id"], expect.objectContaining({ value: someItem })], + + [ + ["some-root-id", "some-id", "some-child-id"], + expect.objectContaining({ value: someNestedItem }), + ], + ]); + }); +}); diff --git a/packages/core/src/common/utils/composite/get-composite-normalization/get-composite-normalization.ts b/packages/core/src/common/utils/composite/get-composite-normalization/get-composite-normalization.ts new file mode 100644 index 0000000000..eb5628171a --- /dev/null +++ b/packages/core/src/common/utils/composite/get-composite-normalization/get-composite-normalization.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite/get-composite"; + +export const getCompositeNormalization = (composite: Composite) => { + const _normalizeComposite = ( + composite: Composite, + previousPath: string[] = [], + ): (readonly [path: string[], composite: Composite])[] => { + const currentPath = [...previousPath, composite.id]; + + const pathAndCompositeTuple = [currentPath, composite] as const; + + return [ + pathAndCompositeTuple, + + ...composite.children.flatMap((child) => + _normalizeComposite(child, currentPath), + ), + ]; + }; + + return _normalizeComposite(composite); +}; diff --git a/packages/core/src/common/utils/composite/get-composite-paths/get-composite-paths.test.ts b/packages/core/src/common/utils/composite/get-composite-paths/get-composite-paths.test.ts new file mode 100644 index 0000000000..079f7e1b83 --- /dev/null +++ b/packages/core/src/common/utils/composite/get-composite-paths/get-composite-paths.test.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getCompositePaths } from "./get-composite-paths"; +import { sortBy } from "lodash/fp"; +import { getCompositeFor } from "../get-composite/get-composite"; + +describe("get-composite-paths", () => { + it("given composite with transformed children, returns paths of transformed children in hierarchical order", () => { + const someRootItem = { + id: "some-root-id", + }; + + const someChildItem1 = { + id: "some-child-id-1", + parentId: "some-root-id", + orderNumber: 1, + }; + + const someChildItem2 = { + id: "some-child-id-2", + parentId: "some-root-id", + orderNumber: 2, + }; + + const someGrandchildItem1 = { + id: "some-grandchild-id-1", + parentId: "some-child-id-1", + orderNumber: 1, + }; + + const someGrandchildItem2 = { + id: "some-grandchild-id-2", + parentId: "some-child-id-1", + orderNumber: 2, + }; + + const items = [ + someRootItem, + // Note: not in order yet. + someChildItem2, + someChildItem1, + someGrandchildItem2, + someGrandchildItem1, + ]; + + const getComposite = getCompositeFor<{ + id: string; + parentId?: string; + orderNumber?: number; + }>({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + transformChildren: children => sortBy(child => child.orderNumber, children), + }); + + const composite = getComposite(items); + + const actual = getCompositePaths(composite); + + expect(actual).toEqual([ + ["some-root-id"], + ["some-root-id", "some-child-id-1"], + ["some-root-id", "some-child-id-1", "some-grandchild-id-1"], + ["some-root-id", "some-child-id-1", "some-grandchild-id-2"], + ["some-root-id", "some-child-id-2"], + ]); + }); +}); diff --git a/packages/core/src/common/utils/composite/get-composite-paths/get-composite-paths.ts b/packages/core/src/common/utils/composite/get-composite-paths/get-composite-paths.ts new file mode 100644 index 0000000000..2b97467431 --- /dev/null +++ b/packages/core/src/common/utils/composite/get-composite-paths/get-composite-paths.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { map } from "lodash/fp"; +import type { Composite } from "../get-composite/get-composite"; +import { getCompositeNormalization } from "../get-composite-normalization/get-composite-normalization"; + +export const getCompositePaths = ( + composite: Composite, +): string[][] => pipeline(composite, getCompositeNormalization, map(([path]) => path)); diff --git a/packages/core/src/common/utils/composite/get-composite/get-composite.test.ts b/packages/core/src/common/utils/composite/get-composite/get-composite.test.ts new file mode 100644 index 0000000000..594d0f3d01 --- /dev/null +++ b/packages/core/src/common/utils/composite/get-composite/get-composite.test.ts @@ -0,0 +1,363 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Composite } from "./get-composite"; +import { getCompositePaths } from "../get-composite-paths/get-composite-paths"; +import { sortBy } from "lodash/fp"; +import { getCompositeFor } from "./get-composite"; + +interface SomeItem { + id: string; + parentId?: string; + orderNumber?: number; +} + +describe("get-composite", () => { + it("given items and an explicit root id, creates a composite", () => { + const someRootItem = { + id: "some-root-id", + someProperty: "some-root-content", + }; + + const someIrrelevantRootItem = { + id: "some-irrelevant-root-id", + someProperty: "some-other-root-content", + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + someProperty: "some-content", + }; + + const someNestedItem = { + id: "some-nested-id", + parentId: "some-id", + someProperty: "some-nested-content", + }; + + const items = [someRootItem, someIrrelevantRootItem, someItem, someNestedItem]; + + const getComposite = getCompositeFor({ + rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + const composite = getComposite(items); + + expect(composite).toEqual({ + id: "some-root-id", + value: someRootItem, + + children: [ + { + id: "some-id", + parentId: "some-root-id", + value: someItem, + + children: [ + { + id: "some-nested-id", + parentId: "some-id", + value: someNestedItem, + children: [], + }, + ], + }, + ], + }); + }); + + it("given items and implicit root, creates a composite", () => { + const someRootItem = { + id: "some-root-id", + someProperty: "some-root-content", + // Notice: no "parentId" makes this the implicit root. + parentId: undefined, + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + someProperty: "some-content", + }; + + const someNestedItem = { + id: "some-nested-id", + parentId: "some-id", + someProperty: "some-nested-content", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const getComposite = getCompositeFor({ + // Notice: no root id + // rootId: "some-root-id", + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + const composite = getComposite(items); + + expect(composite).toEqual({ + id: "some-root-id", + value: someRootItem, + + children: [ + { + id: "some-id", + parentId: "some-root-id", + value: someItem, + + children: [ + { + id: "some-nested-id", + parentId: "some-id", + value: someNestedItem, + children: [], + }, + ], + }, + ], + }); + }); + + it("given items and an unspecified root id and multiple items without parent as root, throws", () => { + const someRootItem = { + id: "some-root-id", + // Notice: no "parentId" makes this a root. + parentId: undefined, + }; + + const someOtherRootItem = { + id: "some-other-root-id", + // Notice: no "parentId" makes also this a root. + parentId: undefined, + }; + + const items = [someRootItem, someOtherRootItem]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + 'Tried to get a composite, but multiple roots where encountered: "some-root-id", "some-other-root-id"', + ); + }); + + it("given non-unique ids, throws", () => { + const someItem = { + id: "some-id", + parentId: "irrelevant", + }; + + const someOtherItem = { + id: "some-id", + parentId: "irrelevant", + }; + + const items = [someItem, someOtherItem]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + 'Tried to get a composite but encountered non-unique ids: "some-id"', + ); + }); + + it("given items with missing parent ids, when creating composite without handling for unknown parents, throws", () => { + const someItem = { + id: "some-id", + parentId: undefined, + }; + + const someItemWithMissingParentId = { + id: "some-other-id", + parentId: "some-missing-id", + }; + + const items = [someItem, someItemWithMissingParentId]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + `Tried to get a composite but encountered missing parent ids: "some-missing-id". + +Available parent ids are: +"some-id", +"some-other-id"`, + ); + }); + + describe("given items with missing parents, when creating composite with handling for missing parents", () => { + let composite: Composite; + let handleMissingParentIdMock: jest.Mock; + + beforeEach(() => { + const someItem = { + id: "some-root-id", + }; + + const someItemWithMissingParentId = { + id: "some-orphan-id", + // Note: the item corresponding to this id does not exist, + // making this item have a "missing parent". + parentId: "some-missing-id", + }; + + const items = [someItem, someItemWithMissingParentId]; + + handleMissingParentIdMock = jest.fn(); + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + handleMissingParentIds: handleMissingParentIdMock, + }); + + composite = getComposite(items); + }); + + it("creates composite without the orphan item, and without throwing", () => { + const paths = getCompositePaths(composite); + + expect(paths).toEqual([["some-root-id"]]); + }); + + it("handles the missing parent ids", () => { + expect(handleMissingParentIdMock).toHaveBeenCalledWith({ + missingParentIds: ["some-missing-id"], + availableParentIds: ["some-root-id", "some-orphan-id"], + }); + }); + }); + + it("given items with same id and parent id, throws", () => { + const someItem = { + id: "some-id", + parentId: "some-id", + }; + + const someRoot = { + id: "root", + parentId: undefined, + }; + + const items = [someItem, someRoot]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow( + 'Tried to get a composite, but found items with self as parent: "some-id"', + ); + }); + + it("given undefined ids, throws", () => { + const root = { + parentId: undefined, + id: "some-root", + }; + + const someItem = { + parentId: "some-root", + id: undefined, + }; + + const someOtherItem = { + parentId: "some-root", + id: undefined, + }; + + const items = [root, someItem, someOtherItem]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + }); + + expect(() => { + getComposite(items); + }).toThrow("Tried to get a composite but encountered 2 undefined ids"); + }); + + it("given transformed children, creates a composite with transformed children", () => { + const someRootItem = { + id: "some-root-id", + orderNumber: 1, + }; + + const someItem1 = { + id: "some-id-1", + parentId: "some-root-id", + orderNumber: 1, + }; + + const someItem2 = { + id: "some-id-2", + parentId: "some-root-id", + orderNumber: 2, + }; + + const someChildItem1 = { + id: "some-child-id-1", + parentId: "some-id-1", + orderNumber: 1, + }; + + const someChildItem2 = { + id: "some-child-id-2", + parentId: "some-id-1", + orderNumber: 2, + }; + + const items = [ + someRootItem, + // Note: not in order yet. + someItem2, + someItem1, + someChildItem2, + someChildItem1, + ]; + + const getComposite = getCompositeFor({ + getId: (x) => x.id, + getParentId: (x) => x.parentId, + transformChildren: (things) => + sortBy((thing) => thing.orderNumber, things), + }); + + const composite = getComposite(items); + + const orderedPaths = getCompositePaths(composite); + + expect(orderedPaths).toEqual([ + ["some-root-id"], + ["some-root-id", "some-id-1"], + ["some-root-id", "some-id-1", "some-child-id-1"], + ["some-root-id", "some-id-1", "some-child-id-2"], + ["some-root-id", "some-id-2"], + ]); + }); +}); diff --git a/packages/core/src/common/utils/composite/get-composite/get-composite.ts b/packages/core/src/common/utils/composite/get-composite/get-composite.ts new file mode 100644 index 0000000000..7e499ad40a --- /dev/null +++ b/packages/core/src/common/utils/composite/get-composite/get-composite.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { pipeline } from "@ogre-tools/fp"; +import { + countBy, + filter, + toPairs, + nth, + map, + uniq, + without, + compact, + identity, +} from "lodash/fp"; + +export interface Composite { + id: string; + parentId: string | undefined; + value: T; + children: Composite[]; +} + +interface Configuration { + rootId?: string; + getId: (thing: T) => string; + getParentId: (thing: T) => string | undefined; + transformChildren?: (things: T[]) => T[]; + handleMissingParentIds?: (parentIdsForHandling: ParentIdsForHandling) => void; +} + +export const getCompositeFor = ({ + rootId = undefined, + getId, + getParentId, + transformChildren = identity, + handleMissingParentIds = throwMissingParentIds, +}: Configuration) => (source: T[]) => { + const undefinedIds = pipeline( + source, + filter((x) => getId(x) === undefined), + ); + + if (undefinedIds.length) { + throw new Error( + `Tried to get a composite but encountered ${undefinedIds.length} undefined ids`, + ); + } + + const selfReferencingIds = pipeline( + source, + filter((x) => getId(x) === getParentId(x)), + map(getId), + ); + + if (selfReferencingIds.length) { + throw new Error( + `Tried to get a composite, but found items with self as parent: "${selfReferencingIds.join( + '", ', + )}"`, + ); + } + + const duplicateIds = pipeline( + source, + countBy(getId), + toPairs, + filter(([, count]) => count > 1), + map(nth(0)), + ); + + if (duplicateIds.length) { + throw new Error( + `Tried to get a composite but encountered non-unique ids: "${duplicateIds + .map((x) => String(x)) + .join('", "')}"`, + ); + } + + const allIds = pipeline(source, map(getId)); + + const allParentIds = pipeline(source, map(getParentId), uniq, compact); + + const missingParentIds = without(allIds, allParentIds); + + if (missingParentIds.length) { + handleMissingParentIds({ missingParentIds, availableParentIds: allIds }); + } + + const toComposite = (thing: T): Composite => { + const thingId = getId(thing); + + return { + id: thingId, + parentId: getParentId(thing), + value: thing, + + children: pipeline( + source, + + filter((childThing) => { + const parentId = getParentId(childThing); + + return parentId === thingId; + }), + + transformChildren, + + map(toComposite), + ), + }; + }; + + const isRootId = rootId + ? (thing: T) => getId(thing) === rootId + : (thing: T) => getParentId(thing) === undefined; + + const roots = source.filter(isRootId); + + if (roots.length > 1) { + throw new Error( + `Tried to get a composite, but multiple roots where encountered: "${roots + .map(getId) + .join('", "')}"`, + ); + } + + return toComposite(roots[0]); + }; + +interface ParentIdsForHandling { + missingParentIds: string[]; + availableParentIds: string[]; +} + +const throwMissingParentIds = ({ + missingParentIds, + availableParentIds, +}: ParentIdsForHandling) => { + throw new Error( + `Tried to get a composite but encountered missing parent ids: "${missingParentIds.join( + '", "', + )}".\n\nAvailable parent ids are:\n"${availableParentIds.join('",\n"')}"`, + ); +}; diff --git a/packages/core/src/common/utils/composite/interfaces.ts b/packages/core/src/common/utils/composite/interfaces.ts new file mode 100644 index 0000000000..1c29a0e4ce --- /dev/null +++ b/packages/core/src/common/utils/composite/interfaces.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface ParentOfChildComposite { + id: Id; +} + +export interface ChildOfParentComposite { + parentId: ParentId; +} + +export type RootComposite = + & { parentId: undefined } + & ParentOfChildComposite; diff --git a/packages/core/src/common/utils/computed-or.ts b/packages/core/src/common/utils/computed-or.ts new file mode 100644 index 0000000000..4e93394924 --- /dev/null +++ b/packages/core/src/common/utils/computed-or.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { IComputedValue } from "mobx"; +import { computed } from "mobx"; + +export const computedOr = (...values: IComputedValue[]) => computed(( + () => values.some(value => value.get()) +)); diff --git a/packages/core/src/common/utils/convertCpu.ts b/packages/core/src/common/utils/convertCpu.ts new file mode 100644 index 0000000000..86b57b78f8 --- /dev/null +++ b/packages/core/src/common/utils/convertCpu.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { TypedRegEx } from "typed-regex"; + +// Helper to convert CPU K8S units to numbers + +const unitConverters = new Map([ + ["n", 1000 ** -3], + ["u", 1000 ** -2], + ["m", 1000 ** -1], // milli + ["", 1000 ** 0], // no units + ["k", 1000 ** 1], + ["M", 1000 ** 2], + ["G", 1000 ** 3], + ["P", 1000 ** 4], + ["T", 1000 ** 5], + ["E", 1000 ** 6], +]); + +const cpuUnitsRegex = TypedRegEx("^(?[+-]?[0-9.]+(e[-+]?[0-9]+)?)(?[EinumkKMGTP]*)$"); + +export function cpuUnitsToNumber(value: string) { + const match = cpuUnitsRegex.captures(value); + + if (!match) { + return undefined; + } + + const { digits = "", unit } = match; + const conversion = unitConverters.get(unit); + + if (conversion === undefined) { + return undefined; + } + + return parseFloat(digits) * conversion; +} diff --git a/packages/core/src/common/utils/convertMemory.ts b/packages/core/src/common/utils/convertMemory.ts new file mode 100644 index 0000000000..7cae1cf0c1 --- /dev/null +++ b/packages/core/src/common/utils/convertMemory.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import assert from "assert"; +import * as iter from "./iter"; + +// Helper to convert memory from units Ki, Mi, Gi, Ti, Pi to bytes and vise versa + +const baseMagnitude = 1024; +const maxMagnitude = ["PiB", baseMagnitude ** 5] as const; +const magnitudes = new Map([ + ["B", 1] as const, + ["KiB", baseMagnitude ** 1] as const, + ["MiB", baseMagnitude ** 2] as const, + ["GiB", baseMagnitude ** 3] as const, + ["TiB", baseMagnitude ** 4] as const, + maxMagnitude, +]); +const unitRegex = /(?[0-9]+(\.[0-9]*)?)(?(B|[KMGTP]iB?))?/; + +type BinaryUnit = typeof magnitudes extends Map ? Key : never; + +export function unitsToBytes(value: string): number { + const unitsMatch = value.match(unitRegex); + + if (!unitsMatch?.groups) { + return NaN; + } + + const parsedValue = parseFloat(unitsMatch.groups.value); + + if (!unitsMatch.groups?.suffix) { + return parsedValue; + } + + const magnitude = magnitudes.get(unitsMatch.groups.suffix as BinaryUnit) + ?? magnitudes.get(`${unitsMatch.groups.suffix}B` as BinaryUnit); + + assert(magnitude, "UnitRegex is wrong some how"); + + return parseInt((parsedValue * magnitude).toFixed(1)); +} + +export interface BytesToUnitesOptions { + /** + * The number of decimal places. MUST be an integer. MUST be in the range [0, 20]. + * @default 1 + */ + precision?: number; +} + +export function bytesToUnits(bytes: number, { precision = 1 }: BytesToUnitesOptions = {}): string { + if (bytes <= 0 || isNaN(bytes) || !isFinite(bytes)) { + return "N/A"; + } + + const index = Math.floor(Math.log(bytes) / Math.log(baseMagnitude)); + const [suffix, magnitude] = iter.nth(magnitudes.entries(), index) ?? maxMagnitude; + + return `${(bytes / magnitude).toFixed(precision)}${suffix}`; +} diff --git a/packages/core/src/common/utils/date/get-current-date-time.ts b/packages/core/src/common/utils/date/get-current-date-time.ts new file mode 100644 index 0000000000..aa4d5e7fa3 --- /dev/null +++ b/packages/core/src/common/utils/date/get-current-date-time.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import moment from "moment"; + +export const getCurrentDateTime = () => moment().utc().format(); + +export const getMillisecondsFromUnixEpoch = () => Date.now(); + +export const getSecondsFromUnixEpoch = () => Math.floor(getMillisecondsFromUnixEpoch() / 1000); diff --git a/packages/core/src/common/utils/debouncePromise.ts b/packages/core/src/common/utils/debouncePromise.ts new file mode 100755 index 0000000000..c15b206a76 --- /dev/null +++ b/packages/core/src/common/utils/debouncePromise.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Debouncing promise evaluation + +export function debouncePromise(func: (...args: F) => T | Promise, timeout = 0): (...args: F) => Promise { + let timer: NodeJS.Timeout; + + return (...params: F) => new Promise(resolve => { + clearTimeout(timer); + timer = global.setTimeout(() => resolve(func(...params)), timeout); + }); +} diff --git a/packages/core/src/common/utils/delay.ts b/packages/core/src/common/utils/delay.ts new file mode 100644 index 0000000000..96171f6535 --- /dev/null +++ b/packages/core/src/common/utils/delay.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type AbortController from "abort-controller"; + +/** + * Return a promise that will be resolved after at least `timeout` ms have + * passed. If `failFast` is provided then the promise is also resolved if it has + * been aborted. + * @param timeout The number of milliseconds before resolving + * @param failFast An abort controller instance to cause the delay to short-circuit + */ +export function delay(timeout = 1000, failFast?: AbortController): Promise { + return new Promise(resolve => { + const timeoutId = setTimeout(resolve, timeout); + + failFast?.signal.addEventListener("abort", () => { + clearTimeout(timeoutId); + resolve(); + }); + }); +} diff --git a/packages/core/src/common/utils/disposer.ts b/packages/core/src/common/utils/disposer.ts new file mode 100644 index 0000000000..2949a7ae35 --- /dev/null +++ b/packages/core/src/common/utils/disposer.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { SingleOrMany } from "./types"; + + + +export interface Disposer { + (): void; +} + +export interface Disposable { + dispose(): void; +} + +export interface ExtendableDisposer extends Disposer { + push(...vals: (Disposer | ExtendableDisposer | Disposable)[]): void; +} + +export function disposer(...items: SingleOrMany[]): ExtendableDisposer { + return Object.assign(() => { + for (const item of items.flat()) { + if (!item) { + continue; + } + + if (typeof item === "function") { + item(); + } else { + item.dispose(); + } + } + items.length = 0; + }, { + push: (...newItems) => items.push(...newItems), + } as Pick); +} diff --git a/packages/core/src/common/utils/escapeRegExp.ts b/packages/core/src/common/utils/escapeRegExp.ts new file mode 100644 index 0000000000..9d5c7e2ff6 --- /dev/null +++ b/packages/core/src/common/utils/escapeRegExp.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Helper to sanitize / escape special chars for passing to RegExp-constructor + +export function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} diff --git a/packages/core/src/common/utils/find-exactly-one/find-exactly-one.test.ts b/packages/core/src/common/utils/find-exactly-one/find-exactly-one.test.ts new file mode 100644 index 0000000000..08dbb9ce72 --- /dev/null +++ b/packages/core/src/common/utils/find-exactly-one/find-exactly-one.test.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { findExactlyOne } from "./find-exactly-one"; + +describe("find-exactly-one", () => { + it("when predicate matches to single item, returns the item", () => { + const actual = findExactlyOne((item) => item === "some-item")([ + "some-item", + "some-other-item", + ]); + + expect(actual).toBe("some-item"); + }); + + it("when predicate matches to many items, throws", () => { + expect(() => { + findExactlyOne((item) => item === "some-item")([ + "some-item", + "some-item", + ]); + }).toThrow("Tried to find exactly one, but found many"); + }); + + it("when predicate does not match, throws", () => { + expect(() => { + findExactlyOne((item) => item === "some-item")([ + "some-other-item", + ]); + }).toThrow("Tried to find exactly one, but didn't find any"); + }); +}); diff --git a/packages/core/src/common/utils/find-exactly-one/find-exactly-one.ts b/packages/core/src/common/utils/find-exactly-one/find-exactly-one.ts new file mode 100644 index 0000000000..181720d8b1 --- /dev/null +++ b/packages/core/src/common/utils/find-exactly-one/find-exactly-one.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export const findExactlyOne = (predicate: (item: T) => boolean) => (collection: T[]): T => { + const itemsFound = collection.filter(predicate); + + if (!itemsFound.length) { + throw new Error( + "Tried to find exactly one, but didn't find any", + ); + } + + if (itemsFound.length > 1) { + throw new Error( + "Tried to find exactly one, but found many", + ); + } + + return itemsFound[0]; +}; diff --git a/packages/core/src/common/utils/formatDuration.ts b/packages/core/src/common/utils/formatDuration.ts new file mode 100644 index 0000000000..1ea4e1e546 --- /dev/null +++ b/packages/core/src/common/utils/formatDuration.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import moment from "moment"; + +/** + * This function formats durations in a more human readable form. + * @param timeValue the duration in milliseconds to format + */ +export function formatDuration(timeValue: number, compact = true) { + const duration = moment.duration(timeValue, "milliseconds"); + const seconds = Math.floor(duration.asSeconds()); + const separator = compact ? "": " "; + + if (seconds < 0) { + return "0s"; + } else if (seconds < 60*2 ) { + return `${seconds}s`; + } + + const minutes = Math.floor(duration.asMinutes()); + + if (minutes < 10) { + const seconds = duration.seconds(); + + return getMeaningfulValues([minutes, seconds], ["m", "s"], separator); + } else if (minutes < 60 * 3) { + if (!compact) { + return getMeaningfulValues([minutes, duration.seconds()], ["m", "s"]); + } + + return `${minutes}m`; + } + + const hours = Math.floor(duration.asHours()); + + if(hours < 8) { + const minutes = duration.minutes(); + + return getMeaningfulValues([hours, minutes], ["h", "m"], separator); + } else if (hours < 48) { + if (compact) { + return `${hours}h`; + } else { + return getMeaningfulValues([hours, duration.minutes()], ["h", "m"]); + } + } + + const days = Math.floor(duration.asDays()); + + if (days < 8) { + const hours = duration.hours(); + + if (compact) { + return getMeaningfulValues([days, hours], ["d", "h"], separator); + } else { + return getMeaningfulValues([days, hours, duration.minutes()], ["d", "h", "m"]); + } + } + const years = Math.floor(duration.asYears()); + + if (years < 2) { + if (compact) { + return `${days}d`; + } else { + return getMeaningfulValues([days, duration.hours(), duration.minutes()], ["d", "h", "m"]); + } + } else if (years < 8) { + const days = duration.days(); + + if (compact) { + return getMeaningfulValues([years, days], ["y", "d"], separator); + } + } + + if (compact) { + return `${years}y`; + } + + return getMeaningfulValues([years, duration.days(), duration.hours(), duration.minutes()], ["y", "d", "h", "m"]); +} + +function getMeaningfulValues(values: number[], suffixes: string[], separator = " ") { + return values + .map((a, i): [number, string] => [a, suffixes[i]]) + .filter(([dur]) => dur > 0) + .map(([dur, suf]) => dur + suf) + .join(separator); +} diff --git a/packages/core/src/common/utils/generate-new-id-for.ts b/packages/core/src/common/utils/generate-new-id-for.ts new file mode 100644 index 0000000000..31aba26e20 --- /dev/null +++ b/packages/core/src/common/utils/generate-new-id-for.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { createHash } from "crypto"; + +export function generateNewIdFor(cluster: { kubeConfigPath: string; contextName: string }): string { + return createHash("md5").update(`${cluster.kubeConfigPath}:${cluster.contextName}`).digest("hex"); +} diff --git a/packages/core/src/common/utils/get-error-message.ts b/packages/core/src/common/utils/get-error-message.ts new file mode 100644 index 0000000000..b5d7dd4244 --- /dev/null +++ b/packages/core/src/common/utils/get-error-message.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export const getErrorMessage = (error: unknown): string => { + if (typeof error === "string") { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + return JSON.stringify(error); +}; diff --git a/packages/core/src/common/utils/get-random-id.global-override-for-injectable.ts b/packages/core/src/common/utils/get-random-id.global-override-for-injectable.ts new file mode 100644 index 0000000000..a0f87b4180 --- /dev/null +++ b/packages/core/src/common/utils/get-random-id.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import getRandomIdInjectable from "./get-random-id.injectable"; + +export default getGlobalOverride(getRandomIdInjectable, () => () => "some-irrelevant-random-id"); diff --git a/packages/core/src/common/utils/get-random-id.injectable.ts b/packages/core/src/common/utils/get-random-id.injectable.ts new file mode 100644 index 0000000000..b6d8b99f41 --- /dev/null +++ b/packages/core/src/common/utils/get-random-id.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { v4 as getRandomId } from "uuid"; + +const getRandomIdInjectable = getInjectable({ + id: "get-random-id", + instantiate: () => () => getRandomId(), + causesSideEffects: true, +}); + +export default getRandomIdInjectable; diff --git a/packages/core/src/common/utils/get-startable-stoppable.test.ts b/packages/core/src/common/utils/get-startable-stoppable.test.ts new file mode 100644 index 0000000000..dc8b24dd43 --- /dev/null +++ b/packages/core/src/common/utils/get-startable-stoppable.test.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { StartableStoppable } from "./get-startable-stoppable"; +import { getStartableStoppable } from "./get-startable-stoppable"; + +describe("getStartableStoppable", () => { + let stopMock: jest.MockedFunction<() => void>; + let startMock: jest.MockedFunction<() => () => void>; + let actual: StartableStoppable; + + beforeEach(() => { + stopMock = jest.fn(); + startMock = jest.fn().mockImplementation(() => stopMock); + actual = getStartableStoppable("some-id", startMock); + }); + + it("does not start yet", () => { + expect(startMock).not.toHaveBeenCalled(); + }); + + it("does not stop yet", () => { + expect(stopMock).not.toHaveBeenCalled(); + }); + + it("when stopping before ever starting, throws", () => { + expect(() => actual.stop()).toThrow("Tried to stop \"some-id\", but it is already stopped."); + }); + + it("is not started", () => { + expect(actual.started).toBe(false); + }); + + describe("when started", () => { + beforeEach(() => { + actual.start(); + }); + + it("calls start function", () => { + expect(startMock).toHaveBeenCalled(); + }); + + it("is started", () => { + expect(actual.started).toBe(true); + }); + + describe("when stopped", () => { + beforeEach(() => { + actual.stop(); + }); + + it("calls stop function", () => { + expect(stopMock).toBeCalled(); + }); + + it("is stopped", () => { + expect(actual.started).toBe(false); + }); + }); + }); +}); diff --git a/packages/core/src/common/utils/get-startable-stoppable.ts b/packages/core/src/common/utils/get-startable-stoppable.ts new file mode 100644 index 0000000000..05d8b4d9af --- /dev/null +++ b/packages/core/src/common/utils/get-startable-stoppable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type Stopper = () => void; +export type Starter = () => Stopper; + +export interface StartableStoppable { + readonly started: boolean; + start: () => void; + stop: () => void; +} + +type StartableStoppableState = "stopped" | "started" | "starting"; + +export function getStartableStoppable(id: string, startAndGetStopper: Starter): StartableStoppable { + let stop: Stopper; + let state: StartableStoppableState = "stopped"; + + return { + get started() { + return state === "started"; + }, + + start: () => { + if (state !== "stopped") { + throw new Error(`Tried to start "${id}", but it is already ${state}.`); + } + + state = "starting"; + stop = startAndGetStopper(); + state = "started"; + }, + + stop: () => { + if (state !== "started") { + throw new Error(`Tried to stop "${id}", but it is already ${state}.`); + } + + stop(); + state = "stopped"; + }, + }; +} diff --git a/packages/core/src/common/utils/getRandId.ts b/packages/core/src/common/utils/getRandId.ts new file mode 100644 index 0000000000..489456d56b --- /dev/null +++ b/packages/core/src/common/utils/getRandId.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Create random system name + +export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) { + const randId = () => Math.random().toString(16).slice(2); + + return [prefix, randId(), suffix].filter(s => s).join(sep); +} diff --git a/packages/core/src/common/utils/hash-set.ts b/packages/core/src/common/utils/hash-set.ts new file mode 100644 index 0000000000..23ce13b1b2 --- /dev/null +++ b/packages/core/src/common/utils/hash-set.ts @@ -0,0 +1,245 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { IInterceptable, IInterceptor, IListenable, ISetWillChange, ObservableMap } from "mobx"; +import { action, observable, ObservableSet } from "mobx"; + +export function makeIterableIterator(iterator: Iterator): IterableIterator { + (iterator as IterableIterator)[Symbol.iterator] = () => iterator as IterableIterator; + + return iterator as IterableIterator; +} + +export class HashSet implements Set { + #hashmap: Map; + + constructor(initialValues: Iterable, protected hasher: (item: T) => string) { + this.#hashmap = new Map(Array.from(initialValues, value => [this.hasher(value), value])); + } + + replace(other: ObservableHashSet | ObservableSet | Set | readonly T[]): this { + if (other === null || other === undefined) { + return this; + } + + if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) { + throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`); + } + + this.clear(); + + for (const value of other) { + this.add(value); + } + + return this; + } + + clear(): void { + this.#hashmap.clear(); + } + + add(value: T): this { + this.#hashmap.set(this.hasher(value), value); + + return this; + } + + toggle(value: T): void { + const hash = this.hasher(value); + + if (this.#hashmap.has(hash)) { + this.#hashmap.delete(hash); + } else { + this.#hashmap.set(hash, value); + } + } + + delete(value: T): boolean { + return this.#hashmap.delete(this.hasher(value)); + } + + forEach(callbackfn: (value: T, key: T, set: Set) => void, thisArg?: any): void { + this.#hashmap.forEach(value => callbackfn(value, value, thisArg ?? this)); + } + + has(value: T): boolean { + return this.#hashmap.has(this.hasher(value)); + } + + get size(): number { + return this.#hashmap.size; + } + + entries(): IterableIterator<[T, T]> { + let nextIndex = 0; + const keys = Array.from(this.keys()); + const values = Array.from(this.values()); + + return makeIterableIterator<[T, T]>({ + next() { + const index = nextIndex++; + + return index < values.length + ? { value: [keys[index], values[index]], done: false } + : { done: true, value: undefined }; + }, + }); + } + + keys(): IterableIterator { + return this.values(); + } + + values(): IterableIterator { + let nextIndex = 0; + const observableValues = Array.from(this.#hashmap.values()); + + return makeIterableIterator({ + next: () => { + return nextIndex < observableValues.length + ? { value: observableValues[nextIndex++], done: false } + : { done: true, value: undefined }; + }, + }); + } + + [Symbol.iterator](): IterableIterator { + return this.#hashmap.values(); + } + + get [Symbol.toStringTag](): string { + return "Set"; + } + + toJSON(): T[] { + return Array.from(this); + } + + toString(): string { + return "[object Set]"; + } +} + +export class ObservableHashSet implements Set, IInterceptable, IListenable { + #hashmap: ObservableMap; + + get interceptors_(): IInterceptor>[] { + return []; + } + + get changeListeners_(): Function[] { + return []; + } + + constructor(initialValues: Iterable, protected hasher: (item: T) => string) { + this.#hashmap = observable.map(Array.from(initialValues, value => [this.hasher(value), value]), undefined); + } + + @action + replace(other: ObservableHashSet | ObservableSet | Set | readonly T[]): this { + if (other === null || other === undefined) { + return this; + } + + if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) { + throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`); + } + + this.clear(); + + for (const value of other) { + this.add(value); + } + + return this; + } + + clear(): void { + this.#hashmap.clear(); + } + + add(value: T): this { + this.#hashmap.set(this.hasher(value), value); + + return this; + } + + @action + toggle(value: T): void { + const hash = this.hasher(value); + + if (this.#hashmap.has(hash)) { + this.#hashmap.delete(hash); + } else { + this.#hashmap.set(hash, value); + } + } + + delete(value: T): boolean { + return this.#hashmap.delete(this.hasher(value)); + } + + forEach(callbackfn: (value: T, key: T, set: Set) => void, thisArg?: any): void { + this.#hashmap.forEach(value => callbackfn(value, value, thisArg ?? this)); + } + + has(value: T): boolean { + return this.#hashmap.has(this.hasher(value)); + } + + get size(): number { + return this.#hashmap.size; + } + + entries(): IterableIterator<[T, T]> { + let nextIndex = 0; + const keys = Array.from(this.keys()); + const values = Array.from(this.values()); + + return makeIterableIterator<[T, T]>({ + next() { + const index = nextIndex++; + + return index < values.length + ? { value: [keys[index], values[index]], done: false } + : { done: true, value: undefined }; + }, + }); + } + + keys(): IterableIterator { + return this.values(); + } + + values(): IterableIterator { + let nextIndex = 0; + const observableValues = Array.from(this.#hashmap.values()); + + return makeIterableIterator({ + next: () => { + return nextIndex < observableValues.length + ? { value: observableValues[nextIndex++], done: false } + : { done: true, value: undefined }; + }, + }); + } + + [Symbol.iterator](): IterableIterator { + return this.#hashmap.values(); + } + + get [Symbol.toStringTag](): string { + return "Set"; + } + + toJSON(): T[] { + return Array.from(this); + } + + toString(): string { + return "[object ObservableSet]"; + } +} diff --git a/packages/core/src/common/utils/index.ts b/packages/core/src/common/utils/index.ts new file mode 100644 index 0000000000..4857d04418 --- /dev/null +++ b/packages/core/src/common/utils/index.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./abort-controller"; +export * from "./autobind"; +export * from "./camelCase"; +export * from "./cluster-id-url-parsing"; +export * from "./collection-functions"; +export * from "./convertCpu"; +export * from "./convertMemory"; +export * from "./debouncePromise"; +export * from "./delay"; +export * from "./disposer"; +export * from "./escapeRegExp"; +export * from "./formatDuration"; +export * from "./getRandId"; +export * from "./hash-set"; +export * from "./n-fircate"; +export * from "./noop"; +export * from "./observable-crate/impl"; +export * from "./promise-exec"; +export * from "./readonly"; +export * from "./reject-promise"; +export * from "./singleton"; +export * from "./sort-compare"; +export * from "./splitArray"; +export * from "./tar"; +export * from "./toJS"; +export * from "./type-narrowing"; +export * from "./types"; +export * from "./wait-for-path"; +export * from "./wait"; + +export type { Tuple } from "./tuple"; + +import * as iter from "./iter"; +import * as array from "./array"; +import * as tuple from "./tuple"; +import * as base64 from "./base64"; +import * as object from "./objects"; +import * as json from "./json"; + +export { + iter, + array, + tuple, + base64, + object, + json, +}; diff --git a/packages/core/src/common/utils/is-promise/is-promise.test.ts b/packages/core/src/common/utils/is-promise/is-promise.test.ts new file mode 100644 index 0000000000..565f272ed6 --- /dev/null +++ b/packages/core/src/common/utils/is-promise/is-promise.test.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { isPromise } from "./is-promise"; + +describe("isPromise", () => { + it("given promise, returns true", () => { + const actual = isPromise(new Promise(() => {})); + + expect(actual).toBe(true); + }); + + it("given non-promise, returns false", () => { + const actual = isPromise({}); + + expect(actual).toBe(false); + }); + + it("given thenable, returns false", () => { + const actual = isPromise({ then: () => {} }); + + expect(actual).toBe(false); + }); + + it("given nothing, returns false", () => { + const actual = isPromise(undefined); + + expect(actual).toBe(false); + }); +}); diff --git a/packages/core/src/common/utils/is-promise/is-promise.ts b/packages/core/src/common/utils/is-promise/is-promise.ts new file mode 100644 index 0000000000..6261f569cd --- /dev/null +++ b/packages/core/src/common/utils/is-promise/is-promise.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export function isPromise(reference: any): reference is Promise { + return reference?.constructor === Promise; +} diff --git a/packages/core/src/common/utils/iter.ts b/packages/core/src/common/utils/iter.ts new file mode 100644 index 0000000000..5b5593d2d4 --- /dev/null +++ b/packages/core/src/common/utils/iter.ts @@ -0,0 +1,238 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type Falsey = false | 0 | "" | null | undefined; + +interface Iterator extends Iterable { + filter(fn: (val: T) => unknown): Iterator; + filterMap(fn: (val: T) => Falsey | U): Iterator; + find(fn: (val: T) => unknown): T | undefined; + collect(fn: (values: Iterable) => U): U; + map(fn: (val: T) => U): Iterator; + flatMap(fn: (val: T) => U[]): Iterator; + join(sep?: string): string; +} + +export function chain(src: IterableIterator): Iterator { + return { + filter: (fn) => chain(filter(src, fn)), + filterMap: (fn) => chain(filterMap(src, fn)), + map: (fn) => chain(map(src, fn)), + flatMap: (fn) => chain(flatMap(src, fn)), + find: (fn) => find(src, fn), + join: (sep) => join(src, sep), + collect: (fn) => fn(src), + [Symbol.iterator]: () => src, + }; +} + +/** + * Create a new type safe empty Iterable + * @returns An `Iterable` that yields 0 items + */ +export function* newEmpty(): IterableIterator {} + +/** + * Creates a new `Iterable` that yields at most n items from src. + * Does not modify `src` which can be used later. + * @param src An initial iterator + * @param n The maximum number of elements to take from src. Yields up to the floor of `n` and 0 items if n < 0 + */ +export function* take(src: Iterable, n: number): IterableIterator { + outer: for (let i = 0; i < n; i += 1) { + for (const item of src) { + yield item; + continue outer; + } + + // if we are here that means that `src` has been exhausted. Don't bother trying again. + break outer; + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` for each item. + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* map(src: Iterable, fn: (from: T) => U): IterableIterator { + for (const from of src) { + yield fn(from); + } +} + +/** + * The single layer flattening of an iterator, discarding `Falsey` values. + * @param src A type that can be iterated over + * @param fn The function that returns either an iterable over items that should be filtered out or a `Falsey` value indicating that it should be ignored + */ +export function* filterFlatMap(src: Iterable, fn: (from: T) => Iterable | Falsey): IterableIterator { + for (const from of src) { + if (!from) { + continue; + } + + const mapping = fn(from); + + if (!mapping) { + continue; + } + + for (const mapped of mapping) { + if (mapped) { + yield mapped; + } + } + } +} + +/** + * Returns a new iterator that yields the items that each call to `fn` would produce + * @param src A type that can be iterated over + * @param fn A function that returns an iterator + */ +export function* flatMap(src: Iterable, fn: (from: T) => Iterable): IterableIterator { + for (const from of src) { + yield* fn(from); + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * items that return a `truthy` value from `fn`. + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filter(src: Iterable, fn: (from: T) => any): IterableIterator { + for (const from of src) { + if (fn(from)) { + yield from; + } + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` when it is `truthy` + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filterMap(src: Iterable, fn: (from: T) => U | Falsey): IterableIterator { + for (const from of src) { + const res = fn(from); + + if (res) { + yield res; + } + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` when it is not null or undefined + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filterMapStrict(src: Iterable, fn: (from: T) => U | null | undefined): IterableIterator { + for (const from of src) { + const res = fn(from); + + if (res != null) { + yield res; + } + } +} + +/** + * Iterate through `src` until `match` returns a truthy value + * @param src A type that can be iterated over + * @param match A function that should return a truthy value for the item that you want to find + * @returns The first entry that `match` returns a truthy value for, or `undefined` + */ +export function find(src: Iterable, match: (i: T) => any): T | undefined { + for (const from of src) { + if (match(from)) { + return from; + } + } + + return void 0; +} + +/** + * Iterate over `src` calling `reducer` with the previous produced value and the current + * yielded value until `src` is exausted. Then return the final value. + * @param src The value to iterate over + * @param reducer A function for producing the next item from an accumilation and the current item + * @param initial The initial value for the iteration + */ +export function reduce>(src: Iterable, reducer: (acc: R, cur: T) => R, initial: R): R; + +export function reduce(src: Iterable, reducer: (acc: R, cur: T) => R, initial: R): R { + let acc = initial; + + for (const item of src) { + acc = reducer(acc, item); + } + + return acc; +} + +/** + * A convenience function for reducing over an iterator of strings and concatenating them together + * @param src The value to iterate over + * @param connector The string value to intersperse between the yielded values + * @returns The concatenated entries of `src` interspersed with copies of `connector` + */ +export function join(src: IterableIterator, connector = ","): string { + const iterSrc = src[Symbol.iterator](); + const first = iterSrc.next(); + + if (first.done === true) { + return ""; + } + + return reduce(iterSrc, (acc, cur) => `${acc}${connector}${cur}`, `${first.value}`); +} + +/** + * Iterate `n` times and then return the next value. + * @param src The value to iterate over + * @param n The zero-index value for the item to return to. + */ +export function nth(src: Iterable, n: number): T | undefined { + const iterator = src[Symbol.iterator](); + + while (n --> 0) { + iterator.next(); + } + + return iterator.next().value; +} + +/** + * A convenience function to get the first item of an iterator + * @param src The value to iterate over + */ +export function first(src: Iterable): T | undefined { + return nth(src, 0); +} + +/** + * Iterate through `src` and return `true` if `fn` returns a thruthy value for every yielded value. + * Otherwise, return `false`. This function short circuits. + * @param src The type to be iterated over + * @param fn A function to check each iteration + */ +export function every(src: Iterable, fn: (val: T) => any): boolean { + for (const val of src) { + if (!fn(val)) { + return false; + } + } + + return true; +} diff --git a/packages/core/src/common/utils/json.ts b/packages/core/src/common/utils/json.ts new file mode 100644 index 0000000000..53d357f05c --- /dev/null +++ b/packages/core/src/common/utils/json.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { JsonValue } from "type-fest"; + +export function parse(input: string): JsonValue { + return JSON.parse(input); +} diff --git a/packages/core/src/common/utils/n-fircate.ts b/packages/core/src/common/utils/n-fircate.ts new file mode 100644 index 0000000000..0f195adc10 --- /dev/null +++ b/packages/core/src/common/utils/n-fircate.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Split an iterable into several arrays with matching fields + * @param from The iterable of items to split up + * @param field The field of each item to split over + * @param parts What each array will be filtered to + * @returns A `parts.length` tuple of `T[]` where each array has matching `field` values + */ +export function nFircate(from: Iterable, field: keyof T, parts: []): []; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field]]): [T[]]; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field], T[typeof field]]): [T[], T[]]; +export function nFircate(from: Iterable, field: keyof T, parts: [T[typeof field], T[typeof field], T[typeof field]]): [T[], T[], T[]]; + +export function nFircate(from: Iterable, field: keyof T, parts: T[typeof field][]): T[][] { + if (new Set(parts).size !== parts.length) { + throw new TypeError("Duplicate parts entries"); + } + + const res = Array.from(parts, () => [] as T[]); + + for (const item of from) { + const index = parts.indexOf(item[field]); + + if (index < 0) { + continue; + } + + res[index].push(item); + } + + return res; +} diff --git a/packages/core/src/common/utils/noop.ts b/packages/core/src/common/utils/noop.ts new file mode 100644 index 0000000000..a9171f2618 --- /dev/null +++ b/packages/core/src/common/utils/noop.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +/** + * A function that does nothing + */ +export function noop(...args: T): void { + return void args; +} diff --git a/packages/core/src/common/utils/objects.ts b/packages/core/src/common/utils/objects.ts new file mode 100644 index 0000000000..9b6355d3e7 --- /dev/null +++ b/packages/core/src/common/utils/objects.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * A better typed version of `Object.fromEntries` where the keys are known to + * be a specific subset + */ +export function fromEntries(entries: Iterable): Record { + return Object.fromEntries(entries) as Record; +} + +export function keys(obj: Partial>): K[]; + +export function keys(obj: Record): K[] { + return Object.keys(obj) as K[]; +} + +export function entries(obj: Partial> | null | undefined): [K, V][]; +export function entries(obj: Partial> | null | undefined): [K, V][]; +export function entries(obj: Record | null | undefined): [K, V][]; + +export function entries(obj: Record | null | undefined): [K, V][] { + if (obj && typeof obj == "object") { + return Object.entries(obj) as never; + } + + return [] as never; +} diff --git a/packages/core/src/common/utils/observable-crate/impl.ts b/packages/core/src/common/utils/observable-crate/impl.ts new file mode 100644 index 0000000000..e9874c9b7d --- /dev/null +++ b/packages/core/src/common/utils/observable-crate/impl.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { observable, runInAction } from "mobx"; +import { getOrInsertMap } from "../collection-functions"; +import { noop } from "../noop"; + +export interface ObservableCrate { + get(): T; + set(value: T): void; +} + +export interface ObservableCrateFactory { + (initialValue: T, transitionHandlers?: ObservableCrateTransitionHandlers): ObservableCrate; +} + +export interface ObservableCrateTransitionHandler { + from: T; + to: T; + onTransition: () => void; +} +export type ObservableCrateTransitionHandlers = ObservableCrateTransitionHandler[]; + +function convertToHandlersMap(handlers: ObservableCrateTransitionHandlers): Map void>> { + const res: ReturnType> = new Map(); + + for (const { from, to, onTransition } of handlers) { + getOrInsertMap(res, from).set(to, onTransition); + } + + return res; +} + +export const observableCrate = ((initialValue, transitionHandlers = []) => { + const crate = observable.box(initialValue); + const handlers = convertToHandlersMap(transitionHandlers); + + return { + get() { + return crate.get(); + }, + set(value) { + const onTransition = handlers.get(crate.get())?.get(value) ?? noop; + + runInAction(() => { + crate.set(value); + onTransition(); + }); + }, + }; +}) as ObservableCrateFactory; diff --git a/packages/core/src/common/utils/observable-crate/observable-crate.test.ts b/packages/core/src/common/utils/observable-crate/observable-crate.test.ts new file mode 100644 index 0000000000..03ee2e43f8 --- /dev/null +++ b/packages/core/src/common/utils/observable-crate/observable-crate.test.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ObservableCrate } from "./impl"; +import { observableCrate } from "./impl"; + +describe("observable-crate", () => { + it("can be constructed with initial value", () => { + expect(() => observableCrate(0)).not.toThrow(); + }); + + it("has a definite type if the initial value is provided", () => { + expect (() => { + const res: ObservableCrate = observableCrate(0); + + void res; + }).not.toThrow(); + }); + + it("accepts an array of transitionHandlers", () => { + expect(() => observableCrate(0, [])).not.toThrow(); + }); + + describe("with a crate over an enum, and some transition handlers", () => { + enum Test { + Start, + T1, + End, + } + + let crate: ObservableCrate; + let correctHandler: jest.MockedFunction<() => void>; + let incorrectHandler: jest.MockedFunction<() => void>; + + beforeEach(() => { + correctHandler = jest.fn(); + incorrectHandler = jest.fn(); + crate = observableCrate(Test.Start, [ + { + from: Test.Start, + to: Test.Start, + onTransition: incorrectHandler, + }, + { + from: Test.Start, + to: Test.T1, + onTransition: correctHandler, + }, + { + from: Test.Start, + to: Test.End, + onTransition: incorrectHandler, + }, + { + from: Test.T1, + to: Test.Start, + onTransition: incorrectHandler, + }, + { + from: Test.T1, + to: Test.T1, + onTransition: incorrectHandler, + }, + { + from: Test.T1, + to: Test.End, + onTransition: incorrectHandler, + }, + { + from: Test.End, + to: Test.Start, + onTransition: incorrectHandler, + }, + { + from: Test.End, + to: Test.T1, + onTransition: incorrectHandler, + }, + { + from: Test.End, + to: Test.End, + onTransition: incorrectHandler, + }, + ]); + }); + + it("initial value is available", () => { + expect(crate.get()).toBe(Test.Start); + }); + + it("does not call any transition handler", () => { + expect(correctHandler).not.toBeCalled(); + expect(incorrectHandler).not.toBeCalled(); + }); + + describe("when setting a new value", () => { + beforeEach(() => { + crate.set(Test.T1); + }); + + it("calls the associated transition handler", () => { + expect(correctHandler).toBeCalled(); + }); + + it("does not call any other transition handler", () => { + expect(incorrectHandler).not.toBeCalled(); + }); + + it("new value is available", () => { + expect(crate.get()).toBe(Test.T1); + }); + }); + }); +}); diff --git a/packages/core/src/common/utils/open-link-in-browser.global-override-for-injectable.ts b/packages/core/src/common/utils/open-link-in-browser.global-override-for-injectable.ts new file mode 100644 index 0000000000..62c1539757 --- /dev/null +++ b/packages/core/src/common/utils/open-link-in-browser.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import openLinkInBrowserInjectable from "./open-link-in-browser.injectable"; + +export default getGlobalOverride(openLinkInBrowserInjectable, () => async () => {}); diff --git a/packages/core/src/common/utils/open-link-in-browser.injectable.ts b/packages/core/src/common/utils/open-link-in-browser.injectable.ts new file mode 100644 index 0000000000..eaa91939e9 --- /dev/null +++ b/packages/core/src/common/utils/open-link-in-browser.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { shell } from "electron"; + +const allowedProtocols = new Set(["http:", "https:"]); + +export type OpenLinkInBrowser = (url: string) => Promise; + +const openLinkInBrowserInjectable = getInjectable({ + id: "open-link-in-browser", + instantiate: (): OpenLinkInBrowser => ( + async (url) => { + const { protocol } = new URL(url); + + if (!allowedProtocols.has(protocol)) { + throw new TypeError("not an http(s) URL"); + } + + await shell.openExternal(url); + } + ), + causesSideEffects: true, +}); + +export default openLinkInBrowserInjectable; diff --git a/packages/core/src/common/utils/promise-exec.ts b/packages/core/src/common/utils/promise-exec.ts new file mode 100644 index 0000000000..e2471d2611 --- /dev/null +++ b/packages/core/src/common/utils/promise-exec.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import * as util from "util"; +import { execFile } from "child_process"; + +export const promiseExecFile = util.promisify(execFile); diff --git a/packages/core/src/common/utils/random-bytes.global-override-for-injectable.ts b/packages/core/src/common/utils/random-bytes.global-override-for-injectable.ts new file mode 100644 index 0000000000..6f83a264e4 --- /dev/null +++ b/packages/core/src/common/utils/random-bytes.global-override-for-injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import randomBytesInjectable from "./random-bytes.injectable"; + +export default getGlobalOverride(randomBytesInjectable, () => async (size) => { + const res = Buffer.alloc(size); + + for (let i = 0; i < size; i += 1) { + res[i] = i; + } + + return res; +}); diff --git a/packages/core/src/common/utils/random-bytes.injectable.ts b/packages/core/src/common/utils/random-bytes.injectable.ts new file mode 100644 index 0000000000..9f00961824 --- /dev/null +++ b/packages/core/src/common/utils/random-bytes.injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { randomBytes } from "crypto"; +import { promisify } from "util"; + +export type RandomBytes = (size: number) => Promise; + +const randomBytesInjectable = getInjectable({ + id: "random-bytes", + instantiate: (): RandomBytes => promisify(randomBytes), + causesSideEffects: true, +}); + +export default randomBytesInjectable; diff --git a/packages/core/src/common/utils/reactive-now/reactive-now.test.tsx b/packages/core/src/common/utils/reactive-now/reactive-now.test.tsx new file mode 100644 index 0000000000..ab9b185438 --- /dev/null +++ b/packages/core/src/common/utils/reactive-now/reactive-now.test.tsx @@ -0,0 +1,70 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RenderResult } from "@testing-library/react"; +import { render } from "@testing-library/react"; +import type { IComputedValue } from "mobx"; +import { computed, observe } from "mobx"; +import React from "react"; +import { observer } from "mobx-react"; +import { advanceFakeTime, testUsingFakeTime } from "../../test-utils/use-fake-time"; +import { reactiveNow } from "./reactive-now"; + +describe("reactiveNow", () => { + let someComputed: IComputedValue; + + beforeEach(() => { + testUsingFakeTime("2015-10-21T07:28:00Z"); + + someComputed = computed(() => { + const currentTimestamp = reactiveNow(); + + return currentTimestamp > new Date("2015-10-21T07:28:00Z").getTime(); + }); + }); + + describe("react-context", () => { + let rendered: RenderResult; + + beforeEach(() => { + const TestComponent = observer( + ({ someComputed }: { someComputed: IComputedValue }) => ( +

{someComputed.get() ? "true" : "false"}
+ ), + ); + + rendered = render(); + }); + + it("given time passes, works", () => { + advanceFakeTime(1000); + + expect(rendered.container.textContent).toBe("true"); + }); + + it("does not share the state from previous test", () => { + expect(rendered.container.textContent).toBe("false"); + }); + }); + + describe("non-react-context", () => { + let actual: boolean; + + beforeEach(() => { + observe(someComputed, (changed) => { + actual = changed.newValue as boolean; + }, true); + }); + + it("given time passes, works", () => { + advanceFakeTime(1000); + + expect(actual).toBe(true); + }); + + it("does not share the state from previous test", () => { + expect(actual).toBe(false); + }); + }); +}); diff --git a/packages/core/src/common/utils/reactive-now/reactive-now.ts b/packages/core/src/common/utils/reactive-now/reactive-now.ts new file mode 100644 index 0000000000..febac37010 --- /dev/null +++ b/packages/core/src/common/utils/reactive-now/reactive-now.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { _isComputingDerivation } from "mobx"; +import type { IResource } from "mobx-utils"; +import { fromResource } from "mobx-utils"; + +// Note: This file is copy-pasted from mobx-utils to fix very specific issue. +// TODO: Remove this file once https://github.com/mobxjs/mobx-utils/issues/306 is fixed. +const tickers: Record> = {}; + +export function reactiveNow(interval?: number | "frame") { + if (interval === void 0) { interval = 1000; } + + if (!_isComputingDerivation()) { + // See #40 + return Date.now(); + } + + // Note: This is the kludge until https://github.com/mobxjs/mobx-utils/issues/306 is fixed + const synchronizationIsEnabled = !process.env.JEST_WORKER_ID; + + if (!tickers[interval] || !synchronizationIsEnabled) { + if (typeof interval === "number") + tickers[interval] = createIntervalTicker(interval); + else + tickers[interval] = createAnimationFrameTicker(); + } + + return tickers[interval].current(); +} + +function createIntervalTicker(interval: number) { + let subscriptionHandle: NodeJS.Timer; + + return fromResource(function (sink) { + sink(Date.now()); + subscriptionHandle = setInterval(function () { return sink(Date.now()); }, interval); + }, function () { + clearInterval(subscriptionHandle); + }, Date.now()); +} + +function createAnimationFrameTicker() { + const frameBasedTicker = fromResource(function (sink) { + sink(Date.now()); + + function scheduleTick() { + window.requestAnimationFrame(function () { + sink(Date.now()); + if (frameBasedTicker.isAlive()) + scheduleTick(); + }); + } + scheduleTick(); + }, function () { }, Date.now()); + + return frameBasedTicker; +} diff --git a/packages/core/src/common/utils/readableStream.ts b/packages/core/src/common/utils/readableStream.ts new file mode 100644 index 0000000000..59fb5ae4f4 --- /dev/null +++ b/packages/core/src/common/utils/readableStream.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { Readable } from "readable-stream"; +import type { TypedArray } from "type-fest"; + +/** + * ReadableWebToNodeStream + * + * Copied from https://github.com/Borewit/readable-web-to-node-stream + * + * Adds read error handler + * + * */ +export class ReadableWebToNodeStream extends Readable { + + public bytesRead = 0; + public released = false; + + /** + * Default web API stream reader + * https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader + */ + private reader: ReadableStreamDefaultReader; + private pendingRead?: Promise>; + + /** + * + * @param stream ReadableStream: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream + */ + constructor(stream: ReadableStream) { + super(); + this.reader = stream.getReader(); + } + + /** + * Implementation of readable._read(size). + * When readable._read() is called, if data is available from the resource, + * the implementation should begin pushing that data into the read queue + * https://nodejs.org/api/stream.html#stream_readable_read_size_1 + */ + public async _read() { + // Should start pushing data into the queue + // Read data from the underlying Web-API-readable-stream + if (this.released) { + this.push(null); // Signal EOF + + return; + } + + try { + this.pendingRead = this.reader.read(); + const data = await this.pendingRead; + + // clear the promise before pushing pushing new data to the queue and allow sequential calls to _read() + delete this.pendingRead; + + if (data.done || this.released) { + this.push(null); // Signal EOF + } else { + this.bytesRead += data.value.length; + this.push(data.value); // Push new data to the queue + } + } catch (error) { + this.push(null); // Signal EOF + } + } + + /** + * If there is no unresolved read call to Web-API ReadableStream immediately returns; + * otherwise will wait until the read is resolved. + */ + public async waitForReadToComplete() { + if (this.pendingRead) { + await this.pendingRead; + } + } + + /** + * Close wrapper + */ + public async close(): Promise { + await this.syncAndRelease(); + } + + private async syncAndRelease() { + this.released = true; + await this.waitForReadToComplete(); + await this.reader.releaseLock(); + } +} diff --git a/packages/core/src/common/utils/readonly.ts b/packages/core/src/common/utils/readonly.ts new file mode 100644 index 0000000000..b379594d9d --- /dev/null +++ b/packages/core/src/common/utils/readonly.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ReadonlyDeep } from "type-fest"; + +export function readonly(src: T): ReadonlyDeep { + return src as ReadonlyDeep; +} diff --git a/packages/core/src/common/utils/reject-promise.ts b/packages/core/src/common/utils/reject-promise.ts new file mode 100644 index 0000000000..8212bacd3f --- /dev/null +++ b/packages/core/src/common/utils/reject-promise.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AbortSignal } from "abort-controller"; + +/** + * Creates a new promise that will be rejected when the signal rejects. + * + * Useful for `Promise.race()` applications. + * @param signal The AbortController's signal to reject with + */ +export function rejectPromiseBy(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + signal.addEventListener("abort", reject); + }); +} diff --git a/packages/core/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.ts b/packages/core/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.ts new file mode 100644 index 0000000000..c823a8a8f9 --- /dev/null +++ b/packages/core/src/common/utils/resolve-system-proxy/resolve-system-proxy-channel.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RequestChannel } from "../channel/request-channel-listener-injection-token"; + +export type ResolveSystemProxyChannel = RequestChannel; + +export const resolveSystemProxyChannel: ResolveSystemProxyChannel = { + id: "resolve-system-proxy-channel", +}; diff --git a/packages/core/src/common/utils/resolve-system-proxy/resolve-system-proxy-injection-token.ts b/packages/core/src/common/utils/resolve-system-proxy/resolve-system-proxy-injection-token.ts new file mode 100644 index 0000000000..616718ea98 --- /dev/null +++ b/packages/core/src/common/utils/resolve-system-proxy/resolve-system-proxy-injection-token.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export type ResolveSystemProxy = (url: string) => Promise; + +export const resolveSystemProxyInjectionToken = getInjectionToken({ + id: "resolve-system-proxy", +}); diff --git a/packages/core/src/common/utils/singleton.ts b/packages/core/src/common/utils/singleton.ts new file mode 100644 index 0000000000..0dea1f7526 --- /dev/null +++ b/packages/core/src/common/utils/singleton.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface StaticThis { new(...args: R): T } + +/** + * @deprecated This is a form of global shared state + */ +export class Singleton { + private static readonly instances = new WeakMap(); + private static creating = ""; + + constructor() { + if (Singleton.creating.length === 0) { + throw new TypeError("A singleton class must be created by createInstance()"); + } + } + + /** + * Creates the single instance of the child class if one was not already created. + * + * Multiple calls will return the same instance. + * Essentially throwing away the arguments to the subsequent calls. + * + * Note: this is a racy function, if two (or more) calls are racing to call this function + * only the first's arguments will be used. + * @param this Implicit argument that is the child class type + * @param args The constructor arguments for the child class + * @returns An instance of the child class + */ + static createInstance(this: StaticThis, ...args: R): T { + if (!Singleton.instances.has(this)) { + if (Singleton.creating.length > 0) { + throw new TypeError(`Cannot create a second singleton (${this.name}) while creating a first (${Singleton.creating})`); + } + + try { + Singleton.creating = this.name; + Singleton.instances.set(this, new this(...args)); + } finally { + Singleton.creating = ""; + } + } + + return Singleton.instances.get(this) as T; + } + + /** + * Get the instance of the child class that was previously created. + * @param this Implicit argument that is the child class type + * @param strict If false will return `undefined` instead of throwing when an instance doesn't exist. + * Default: `true` + * @returns An instance of the child class + */ + static getInstance(this: StaticThis, strict?: true): T; + static getInstance(this: StaticThis, strict: false): T | undefined; + static getInstance(this: StaticThis, strict = true): T | undefined { + if (!Singleton.instances.has(this) && strict) { + throw new TypeError(`instance of ${this.name} is not created`); + } + + return Singleton.instances.get(this) as (T | undefined); + } + + /** + * Delete the instance of the child class. + * + * Note: this doesn't prevent callers of `getInstance` from storing the result in a global. + * + * There is *no* way in JS or TS to prevent globals like that. + */ + static resetInstance() { + Singleton.instances.delete(this); + } +} + +export default Singleton; diff --git a/packages/core/src/common/utils/sort-compare.ts b/packages/core/src/common/utils/sort-compare.ts new file mode 100644 index 0000000000..35d88c01b9 --- /dev/null +++ b/packages/core/src/common/utils/sort-compare.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import semver, { coerce } from "semver"; + +export enum Ordering { + LESS = -1, + EQUAL = 0, + GREATER = 1, +} + +/** + * This function switches the direction of `ordering` if `direction` is `"desc"` + * @param ordering The original ordering (assumed to be an "asc" ordering) + * @param direction The new desired direction + */ +export function rectifyOrdering(ordering: Ordering, direction: "asc" | "desc"): Ordering { + if (direction === "desc") { + return -ordering; + } + + return ordering; +} + +/** + * An ascending sorting function + * @param left An item from an array + * @param right An item from an array + * @returns The relative ordering in an ascending manner. + * - Less if left < right + * - Equal if left == right + * - Greater if left > right + */ +export function sortCompare(left: T, right: T): Ordering { + if (left < right) { + return Ordering.LESS; + } + + if (left === right) { + return Ordering.EQUAL; + } + + return Ordering.GREATER; +} + +/** + * This function sorts of list of items that have what should be a semver version formated string + * as the field `version` but if it is not loosely coercable to semver falls back to sorting them + * alphanumerically + */ +export function sortBySemverVersion(versioneds: T[]): T[] { + return versioneds + .map(versioned => ({ + __version: coerce(versioned.version, { loose: true }), + raw: versioned, + })) + .sort((left, right) => { + if (left.__version && right.__version) { + return semver.compare(right.__version, left.__version); + } + + if (!left.__version && right.__version) { + return Ordering.GREATER; + } + + if (left.__version && !right.__version) { + return Ordering.LESS; + } + + return sortCompare(left.raw.version, right.raw.version); + }) + .map(({ raw }) => raw); +} diff --git a/packages/core/src/common/utils/sort-function.ts b/packages/core/src/common/utils/sort-function.ts new file mode 100644 index 0000000000..4eeab57746 --- /dev/null +++ b/packages/core/src/common/utils/sort-function.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Get an ordering function based on the function getter + */ +export function byValue(getOrderValue: (src: T) => number): (left: T, right: T) => number { + return (left, right) => { + const leftValue = getOrderValue(left); + const rightValue = getOrderValue(right); + + return leftValue - rightValue; + }; +} diff --git a/packages/core/src/common/utils/splitArray.ts b/packages/core/src/common/utils/splitArray.ts new file mode 100644 index 0000000000..142d4a33ff --- /dev/null +++ b/packages/core/src/common/utils/splitArray.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * This function splits an array into two sub arrays on the first instance of + * element (from the left). If the array does not contain the element. The + * return value is defined to be `[array, [], false]`. If the element is in + * the array then the return value is `[left, right, true]` where `left` is + * the elements of `array` from `[0, index)` and `right` is `(index, length)` + * @param array the full array to split into two sub-arrays + * @param element the element in the middle of the array + * @returns the left and right sub-arrays which when conjoined with `element` + * is the same as `array`, and `true` + */ +export function splitArray(array: T[], element: T): [T[], T[], boolean] { + const index = array.indexOf(element); + + if (index < 0) { + return [array, [], false]; + } + + return [array.slice(0, index), array.slice(index + 1, array.length), true]; +} + +/** + * Splits an array into two parts based on the outcome of `condition`. If `true` + * the value will be returned as part of the right array. If `false` then part of + * the left array. + * @param src the full array to bifurcate + * @param condition the function to determine which set each is in + */ +export function bifurcateArray(src: T[], condition: (item: T) => boolean): [falses: T[], trues: T[]] { + const res: [T[], T[]] = [[], []]; + + for (const item of src) { + res[+condition(item)].push(item); + } + + return res; +} diff --git a/packages/core/src/common/utils/sync-box/channel-listener.injectable.ts b/packages/core/src/common/utils/sync-box/channel-listener.injectable.ts new file mode 100644 index 0000000000..a97d95d726 --- /dev/null +++ b/packages/core/src/common/utils/sync-box/channel-listener.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { syncBoxChannel } from "./channels"; +import { getMessageChannelListenerInjectable } from "../channel/message-channel-listener-injection-token"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; + +const syncBoxChannelListenerInjectable = getMessageChannelListenerInjectable({ + id: "init", + channel: syncBoxChannel, + handler: (di) => ({ id, value }) => di.inject(syncBoxStateInjectable, id).set(value), +}); + +export default syncBoxChannelListenerInjectable; diff --git a/packages/core/src/common/utils/sync-box/channels.ts b/packages/core/src/common/utils/sync-box/channels.ts new file mode 100644 index 0000000000..4df0462dc3 --- /dev/null +++ b/packages/core/src/common/utils/sync-box/channels.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { MessageChannel } from "../channel/message-channel-listener-injection-token"; +import type { RequestChannel } from "../channel/request-channel-listener-injection-token"; + +export type SyncBoxChannel = MessageChannel<{ id: string; value: any }>; + +export const syncBoxChannel: SyncBoxChannel = { + id: "sync-box-channel", +}; + +export type SyncBoxInitialValueChannel = RequestChannel< + void, + { id: string; value: any }[] +>; + +export const syncBoxInitialValueChannel: SyncBoxInitialValueChannel = { + id: "sync-box-initial-value-channel", +}; diff --git a/packages/core/src/common/utils/sync-box/create-sync-box.injectable.ts b/packages/core/src/common/utils/sync-box/create-sync-box.injectable.ts new file mode 100644 index 0000000000..4a01fe71a0 --- /dev/null +++ b/packages/core/src/common/utils/sync-box/create-sync-box.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IObservableValue } from "mobx"; +import { computed } from "mobx"; +import { syncBoxChannel } from "./channels"; +import { sendMessageToChannelInjectionToken } from "../channel/message-to-channel-injection-token"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; +import type { SyncBox } from "./sync-box-injection-token"; +import { toJS } from "../toJS"; + +const createSyncBoxInjectable = getInjectable({ + id: "create-sync-box", + + instantiate: (di) => { + const messageToChannel = di.inject(sendMessageToChannelInjectionToken); + const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); + + return (id: string, initialValue: Value): SyncBox => { + const state = getSyncBoxState(id) as IObservableValue; + + state.set(initialValue); + + return { + id, + + value: computed(() => toJS(state.get())), + + set: (value) => { + state.set(value); + + messageToChannel(syncBoxChannel, { id, value }); + }, + }; + }; + }, +}); + +export default createSyncBoxInjectable; + diff --git a/packages/core/src/common/utils/sync-box/handler.injectable.ts b/packages/core/src/common/utils/sync-box/handler.injectable.ts new file mode 100644 index 0000000000..f520585474 --- /dev/null +++ b/packages/core/src/common/utils/sync-box/handler.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { MessageChannelHandler } from "../channel/message-channel-listener-injection-token"; +import type { SyncBoxChannel } from "./channels"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; + +const syncBoxChannelHandlerInjectable = getInjectable({ + id: "sync-box-channel-handler", + instantiate: (di): MessageChannelHandler => { + const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); + + return ({ id, value }) => getSyncBoxState(id)?.set(value); + }, +}); + +export default syncBoxChannelHandlerInjectable; diff --git a/packages/core/src/common/utils/sync-box/sync-box-injection-token.ts b/packages/core/src/common/utils/sync-box/sync-box-injection-token.ts new file mode 100644 index 0000000000..8db80243d3 --- /dev/null +++ b/packages/core/src/common/utils/sync-box/sync-box-injection-token.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +export interface SyncBox { + id: string; + value: IComputedValue; + set: (value: Value) => void; +} + +export const syncBoxInjectionToken = getInjectionToken>({ + id: "sync-box", +}); diff --git a/packages/core/src/common/utils/sync-box/sync-box-state.injectable.ts b/packages/core/src/common/utils/sync-box/sync-box-state.injectable.ts new file mode 100644 index 0000000000..e695833da4 --- /dev/null +++ b/packages/core/src/common/utils/sync-box/sync-box-state.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +const syncBoxStateInjectable = getInjectable({ + id: "sync-box-state", + + instantiate: () => observable.box(), + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, id: string) => id, + }), +}); + +export default syncBoxStateInjectable; diff --git a/packages/core/src/common/utils/sync-box/sync-box.test.ts b/packages/core/src/common/utils/sync-box/sync-box.test.ts new file mode 100644 index 0000000000..95f139a8cd --- /dev/null +++ b/packages/core/src/common/utils/sync-box/sync-box.test.ts @@ -0,0 +1,172 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { observe, runInAction } from "mobx"; +import type { ApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import createSyncBoxInjectable from "./create-sync-box.injectable"; +import type { SyncBox } from "./sync-box-injection-token"; +import { syncBoxInjectionToken } from "./sync-box-injection-token"; + +describe("sync-box", () => { + let applicationBuilder: ApplicationBuilder; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(mainDi => { + runInAction(() => { + mainDi.register(someInjectable); + }); + }); + + applicationBuilder.beforeWindowStart((windowDi) => { + runInAction(() => { + windowDi.register(someInjectable); + }); + }); + }); + + describe("given application is started, when value is set in main", () => { + let valueInMain: string; + let syncBoxInMain: SyncBox; + + beforeEach(async () => { + await applicationBuilder.startHidden(); + + syncBoxInMain = applicationBuilder.mainDi.inject(someInjectable); + + observe(syncBoxInMain.value, ({ newValue }) => { + valueInMain = newValue as string; + }, true); + + runInAction(() => { + syncBoxInMain.set("some-value-from-main"); + }); + }); + + it("knows value in main", () => { + expect(valueInMain).toBe("some-value-from-main"); + }); + + describe("when window starts", () => { + let valueInRenderer: string; + let syncBoxInRenderer: SyncBox; + let rendererDi: DiContainer; + + beforeEach(async () => { + const applicationWindow = + applicationBuilder.applicationWindow.create("some-window-id"); + + await applicationWindow.start(); + + rendererDi = applicationWindow.di; + + syncBoxInRenderer = rendererDi.inject(someInjectable); + + observe(syncBoxInRenderer.value, ({ newValue }) => { + valueInRenderer = newValue as string; + }, true); + }); + + it("has the value from main", () => { + expect(valueInRenderer).toBe("some-value-from-main"); + }); + + describe("when value is set from renderer", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + }); + + describe("when application starts with a window", () => { + let valueInRenderer: string; + let valueInMain: string; + let syncBoxInMain: SyncBox; + let syncBoxInRenderer: SyncBox; + + beforeEach(async () => { + await applicationBuilder.render(); + + const applicationWindow = applicationBuilder.applicationWindow.only; + + syncBoxInMain = applicationBuilder.mainDi.inject(someInjectable); + syncBoxInRenderer = applicationWindow.di.inject(someInjectable); + + observe(syncBoxInRenderer.value, ({ newValue }) => { + valueInRenderer = newValue as string; + }, true); + + observe(syncBoxInMain.value, ({ newValue }) => { + valueInMain = newValue as string; + }, true); + }); + + it("knows initial value in main", () => { + expect(valueInMain).toBe("some-initial-value"); + }); + + it("knows initial value in renderer", () => { + expect(valueInRenderer).toBe("some-initial-value"); + }); + + describe("when value is set from main", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInMain.set("some-value-from-main"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-main"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-main"); + }); + + describe("when value is set from renderer", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + }); +}); + +const someInjectable = getInjectable({ + id: "some-injectable", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("some-sync-box", "some-initial-value"); + }, + + injectionToken: syncBoxInjectionToken, +}); diff --git a/packages/core/src/common/utils/tar.ts b/packages/core/src/common/utils/tar.ts new file mode 100644 index 0000000000..3102098976 --- /dev/null +++ b/packages/core/src/common/utils/tar.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Helper for working with tarball files (.tar, .tgz) +// Docs: https://github.com/npm/node-tar +import tar from "tar"; +import path from "path"; +import { parse } from "./json"; +import type { JsonValue } from "type-fest"; + +export type ReadFileFromTarOpts = { + tarPath: string; + filePath: string; +} & ( + ParseJson extends true + ? { + parseJson: true; + } + : { + parseJson?: false; + } +); + +export function readFileFromTar(opts: ReadFileFromTarOpts): Promise; +export function readFileFromTar(opts: ReadFileFromTarOpts): Promise; + +export function readFileFromTar({ tarPath, filePath, parseJson = false }: ReadFileFromTarOpts): Promise { + return new Promise((resolve, reject) => { + const fileChunks: Buffer[] = []; + + tar.list({ + file: tarPath, + filter: entryPath => path.normalize(entryPath) === filePath, + sync: true, + onentry(entry) { + entry.on("data", chunk => { + fileChunks.push(chunk); + }); + entry.once("error", err => { + reject(new Error(`reading file has failed ${entry.path}: ${err}`)); + }); + entry.once("end", () => { + const data = Buffer.concat(fileChunks); + const result = parseJson ? parse(data.toString("utf8")) : data; + + resolve(result); + }); + }, + }); + + if (!fileChunks.length) { + reject(new Error("Not found")); + } + }); +} + +export async function listTarEntries(filePath: string): Promise { + const entries: string[] = []; + + await tar.list({ + file: filePath, + onentry: (entry) => { + entries.push(path.normalize(entry.path)); + }, + }); + + return entries; +} diff --git a/packages/core/src/common/utils/toJS.ts b/packages/core/src/common/utils/toJS.ts new file mode 100644 index 0000000000..7a949b46fc --- /dev/null +++ b/packages/core/src/common/utils/toJS.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Wrapper for mobx.toJS() to support partially observable objects as data-input (>= mobx6). + * Otherwise, output result won't be recursively converted to corresponding plain JS-structure. + * + * @example + * mobx.toJS({one: 1, two: observable.array([2])}); // "data.two" == ObservableArray + */ +import * as mobx from "mobx"; +import { isObservable, observable } from "mobx"; + +export function toJS(data: T): T { + // make data observable for recursive toJS()-output + if (typeof data === "object" && !isObservable(data)) { + return mobx.toJS(observable.box(data).get()); + } + + return mobx.toJS(data); +} diff --git a/packages/core/src/common/utils/tuple.ts b/packages/core/src/common/utils/tuple.ts new file mode 100644 index 0000000000..1268678617 --- /dev/null +++ b/packages/core/src/common/utils/tuple.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import * as array from "../utils/array"; + +/** + * A strict N-tuple of type T + */ +export type Tuple = N extends N + ? number extends N + ? T[] + : TupleOfImpl + : never; +type TupleOfImpl = R["length"] extends N + ? R + : TupleOfImpl; + +/** + * Iterates over `sources` yielding full tuples until one of the tuple arrays + * is empty. Then it returns a tuple with the rest of each of tuples + * @param sources The source arrays + * @yields A tuple of the next element from each of the sources + * @returns The tuple of all the sources as soon as at least one of the sources is exausted + */ +export function zip(...sources: Tuple): Iterator, Tuple>; +export function zip(...sources: Tuple): Iterator, Tuple>; +export function zip(...sources: Tuple): Iterator, Tuple>; + +export function* zip(...sources: Tuple): Iterator, Tuple> { + const maxSafeLength = Math.min(...sources.map(source => source.length)); + + for (let i = 0; i < maxSafeLength; i += 1) { + yield sources.map(source => source[i]) as Tuple; + } + + return sources.map(source => source.slice(maxSafeLength)) as Tuple; +} + +/** + * Returns a `length` tuple filled with copies of `value` + * @param length The size of the tuple + * @param value The value for each of the tuple entries + */ +export function filled(length: L, value: T): Tuple { + return array.filled(length, value) as Tuple; +} + +/** + * A function for converting an explicit array to a tuple but without the `readonly` typing + */ +export function from(...args: T): [...T] { + return args; +} diff --git a/packages/core/src/common/utils/type-narrowing.ts b/packages/core/src/common/utils/type-narrowing.ts new file mode 100644 index 0000000000..41552c3136 --- /dev/null +++ b/packages/core/src/common/utils/type-narrowing.ts @@ -0,0 +1,242 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ExecException, ExecFileException } from "child_process"; +import type { IncomingMessage } from "http"; + +/** + * Narrows `val` to include the property `key` (if true is returned) + * @param val The object to be tested + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) + */ +export function hasOwnProperty(val: S, key: K): val is (S & { [key in K]: unknown }) { + // this call syntax is for when `val` was created by `Object.create(null)` + return Object.prototype.hasOwnProperty.call(val, key); +} + +/** + * Narrows `val` to a static type that includes fields of names in `keys` + * @param val the value that we are trying to type narrow + * @param keys the key names (must be literals for tsc to do any meaningful typing) + */ +export function hasOwnProperties(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) { + return keys.every(key => hasOwnProperty(val, key)); +} + +/** + * Narrows `val` to include the property `key` with type `V` + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) + * @param isValid a function to check if the field is valid + */ +export function hasTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) { + return hasOwnProperty(val, key) && isValid(val[key]); +} + +/** + * Narrows `val` to include the property `key` with type string + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) + */ +export function hasStringProperty(val: S, key: K): val is (S & { [key in K]: string }) { + return hasOwnProperty(val, key) && isString(val[key]); +} + +/** + * Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) + * @param isValid a function to check if the field (when present) is valid + */ +export function hasOptionalTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { + if (hasOwnProperty(val, key)) { + return typeof val[key] === "undefined" || isValid(val[key]); + } + + return true; +} + +/** + * isRecord checks if `val` matches the signature `Record` or `{ [label in T]: V }` + * @param val The value to be checked + * @param isKey a function for checking if the key is of the correct type + * @param isValue a function for checking if a value is of the correct type + */ +export function isRecord(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record { + return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value)); +} + +/** + * isTypedArray checks if `val` is an array and all of its entries are of type `T` + * @param val The value to be checked + * @param isEntry a function for checking if an entry is the correct type + */ +export function isTypedArray(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] { + return Array.isArray(val) && val.every(isEntry); +} + +/** + * checks if val is of type string + * @param val the value to be checked + */ +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +/** + * checks if val is of type Buffer + * @param val the value to be checked + */export function isBuffer(val: unknown): val is Buffer { + return val instanceof Buffer; +} + +/** + * checks if val is of type number + * @param val the value to be checked + */ +export function isNumber(val: unknown): val is number { + return typeof val === "number"; +} + +/** + * checks if val is of type boolean + * @param val the value to be checked + */ +export function isBoolean(val: unknown): val is boolean { + return typeof val === "boolean"; +} + +/** + * checks if val is of type object and isn't null + * @param val the value to be checked + */ +export function isObject(val: unknown): val is Record { + return typeof val === "object" && val !== null; +} + +/** + * checks if `val` is defined, useful for filtering out undefined values in a strict manner + */ +export function isDefined(val: T | undefined | null): val is T { + return val != null; +} + +export function isFunction(val: unknown): val is (...args: unknown[]) => unknown { + return typeof val === "function"; +} + +/** + * Checks if the value in the second position is non-nullable + */ +export function hasDefinedTupleValue(pair: [K, V | undefined | null]): pair is [K, V] { + return pair[1] != null; +} + +/** + * Creates a new predicate function (with the same predicate) from `fn`. Such + * that it can be called with just the value to be tested. + * + * This is useful for when using `hasOptionalProperty` and `hasTypedProperty` + * @param fn A typescript user predicate function to be bound + * @param boundArgs the set of arguments to be passed to `fn` in the new function + */ +export function bindPredicate(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T { + return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs); +} + +export function hasDefiniteField(field: Field): (val: T) => val is T & { [f in Field]-?: NonNullable } { + return (val): val is T & { [f in Field]-?: NonNullable } => val[field] != null; +} + +export function isPromiseLike(res: unknown): res is (Promise | { then: (fn: (val: unknown) => any) => Promise }) { + if (res instanceof Promise) { + return true; + } + + return isObject(res) + && hasTypedProperty(res, "then", isFunction); +} + +export function isPromiseSettledRejected(result: PromiseSettledResult): result is PromiseRejectedResult { + return result.status === "rejected"; +} + +export function isPromiseSettledFulfilled(result: PromiseSettledResult): result is PromiseFulfilledResult { + return result.status === "fulfilled"; +} + +export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return isObject(error) + && hasOptionalTypedProperty(error, "code", isString) + && hasOptionalTypedProperty(error, "path", isString) + && hasOptionalTypedProperty(error, "syscall", isString) + && hasOptionalTypedProperty(error, "errno", isNumber) + && error instanceof Error; +} + +export function isExecException(error: unknown): error is ExecException { + return isObject(error) + && hasOptionalTypedProperty(error, "cmd", isString) + && hasOptionalTypedProperty(error, "killed", isBoolean) + && hasOptionalTypedProperty(error, "signal", isString) + && hasOptionalTypedProperty(error, "code", isNumber) + && error instanceof Error; +} + +export function isExecFileException(error: unknown): error is ExecFileException { + return isExecException(error) && isErrnoException(error); +} + +export type OutputFormat = "string" | "buffer"; +export type ComputeOutputFormat = Format extends "string" + ? string + : Format extends "buffer" + ? Buffer + : string | Buffer; + +export interface ChildProcessExecpetion extends ExecFileException { + stderr: ComputeOutputFormat; + stdout: ComputeOutputFormat; +} + +const isStringOrBuffer = (val: unknown): val is string | Buffer => isString(val) || isBuffer(val); + +export function isChildProcessError(error: unknown, format?: OutputFormat): error is ChildProcessExecpetion { + if (!isExecFileException(error)) { + return false; + } + + if (format === "string") { + return hasTypedProperty(error, "stderr", isString) + && hasTypedProperty(error, "stdout", isString); + } else if (format === "buffer") { + return hasTypedProperty(error, "stderr", isBuffer) + && hasTypedProperty(error, "stdout", isBuffer); + } else { + return hasTypedProperty(error, "stderr", isStringOrBuffer) + && hasTypedProperty(error, "stdout", isStringOrBuffer); + } +} + +export interface RequestLikeError extends Error { + statusCode?: number; + failed?: boolean; + timedOut?: boolean; + error?: string; + response?: IncomingMessage & { body?: any }; +} + +/** + * A type guard for checking if the error is similar in shape to a request package error + */ +export function isRequestError(error: unknown): error is RequestLikeError { + return isObject(error) + && hasOptionalTypedProperty(error, "statusCode", isNumber) + && hasOptionalTypedProperty(error, "failed", isBoolean) + && hasOptionalTypedProperty(error, "timedOut", isBoolean) + && hasOptionalTypedProperty(error, "error", isString) + && hasOptionalTypedProperty(error, "response", isObject) + && error instanceof Error; +} diff --git a/packages/core/src/common/utils/types.ts b/packages/core/src/common/utils/types.ts new file mode 100644 index 0000000000..32ebe14e33 --- /dev/null +++ b/packages/core/src/common/utils/types.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { SetRequired } from "type-fest"; + +export type RemoveUndefinedFromValues = { + [P in keyof K]: NonNullable; +}; + +/** + * This type helps define which fields of some type will always be defined + */ +export type Defaulted = RemoveUndefinedFromValues>> & Omit; + +export type OptionVarient = { + type: Key; +} & Pick & { + [OtherKey in Exclude]?: undefined; +}; + +export type SingleOrMany = T | T[]; + +export type IfEquals = + (() => G extends T ? 1 : 2) extends + (() => G extends U ? 1 : 2) ? Y : N; + +export type MaybeSetRequired = Query extends true + ? SetRequired + : BaseType; diff --git a/packages/core/src/common/utils/union-env-path.ts b/packages/core/src/common/utils/union-env-path.ts new file mode 100644 index 0000000000..991e2c776c --- /dev/null +++ b/packages/core/src/common/utils/union-env-path.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import * as iter from "./iter"; + +/** + * Join all entires with a PATH env var delimated string together + * @param PATHs Any number of PATH env variables + * + * NOTE: This function does not attempt to handle any sort of escape sequences since after testing + * it was found that `zsh` (at least on `macOS`) does not when trying to find programs + */ +export function unionPATHs(...PATHs: string[]): string { + const entries = new Set(iter.filterFlatMap(PATHs, PATH => PATH.split(path.delimiter))); + + return iter.join(entries.values(), path.delimiter); +} diff --git a/packages/core/src/common/utils/wait-for-path.ts b/packages/core/src/common/utils/wait-for-path.ts new file mode 100644 index 0000000000..f5a068075b --- /dev/null +++ b/packages/core/src/common/utils/wait-for-path.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { FSWatcher } from "chokidar"; +import path from "path"; + +/** + * Wait for `filePath` and all parent directories to exist. + * @param pathname The file path to wait until it exists + * + * NOTE: There is technically a race condition in this function of the form + * "time-of-check to time-of-use" because we have to wait for each parent + * directory to exist first. + */ +export async function waitForPath(pathname: string): Promise { + const dirOfPath = path.dirname(pathname); + + if (dirOfPath === pathname) { + // The root of this filesystem, assume it exists + return; + } else { + await waitForPath(dirOfPath); + } + + return new Promise((resolve, reject) => { + const watcher = new FSWatcher({ + depth: 0, + disableGlobbing: true, + }); + const onAddOrAddDir = (filePath: string) => { + if (filePath === pathname) { + watcher.unwatch(dirOfPath); + watcher + .close() + .then(() => resolve()) + .catch(reject); + } + }; + const onError = (error: any) => { + watcher.unwatch(dirOfPath); + watcher + .close() + .then(() => reject(error)) + .catch(() => reject(error)); + }; + + watcher + .on("add", onAddOrAddDir) + .on("addDir", onAddOrAddDir) + .on("error", onError) + .add(dirOfPath); + }); +} diff --git a/packages/core/src/common/utils/wait.ts b/packages/core/src/common/utils/wait.ts new file mode 100644 index 0000000000..402d556b5d --- /dev/null +++ b/packages/core/src/common/utils/wait.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { IComputedValue } from "mobx"; +import { runInAction, when } from "mobx"; +import type { Disposer } from "./disposer"; + +export async function waitUntilDefined(getter: (() => T | null | undefined) | IComputedValue, opts?: { timeout?: number }): Promise { + return new Promise((resolve, reject) => { + when( + () => { + const res = typeof getter === "function" + ? getter() + : getter.get(); + const isDefined = res != null; + + if (isDefined) { + resolve(res); + } + + return isDefined; + }, + () => {}, + { + onError: reject, + ...(opts ?? {}), + }, + ); + }); +} + +export function onceDefined(getter: () => T | null | undefined, action: (val: T) => void): Disposer { + let res: T | null | undefined; + + return when( + () => { + res = getter(); + + if (res != null) { + const r = res; + + runInAction(() => { + action(r); + }); + + return true; + } + + return false; + }, + () => {}, + ); +} diff --git a/packages/core/src/common/utils/with-concurrency-limit.ts b/packages/core/src/common/utils/with-concurrency-limit.ts new file mode 100644 index 0000000000..284bb334e1 --- /dev/null +++ b/packages/core/src/common/utils/with-concurrency-limit.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import plimit from "p-limit"; + +export type ConcurrencyLimiter = (fn: (...args: Args) => Res) => (...args: Args) => Promise; + +export function withConcurrencyLimit(limit: number): ConcurrencyLimiter { + const limiter = plimit(limit); + + return fn => (...args) => limiter(() => fn(...args)); +} diff --git a/packages/core/src/common/utils/with-error-logging/with-error-logging.injectable.ts b/packages/core/src/common/utils/with-error-logging/with-error-logging.injectable.ts new file mode 100644 index 0000000000..0aaca9f97d --- /dev/null +++ b/packages/core/src/common/utils/with-error-logging/with-error-logging.injectable.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import logErrorInjectable from "../../log-error.injectable"; +import { isPromise } from "../is-promise/is-promise"; + +export type WithErrorLoggingFor = ( + getErrorMessage: (error: unknown) => string +) => any>( + toBeDecorated: T +) => (...args: Parameters) => ReturnType; + +const withErrorLoggingInjectable = getInjectable({ + id: "with-error-logging", + + instantiate: (di): WithErrorLoggingFor => { + const logError = di.inject(logErrorInjectable); + + return (getErrorMessage) => + (toBeDecorated) => + (...args) => { + let returnValue: ReturnType; + + try { + returnValue = toBeDecorated(...args); + } catch (e) { + const errorMessage = getErrorMessage(e); + + logError(errorMessage, e); + + throw e; + } + + if (isPromise(returnValue)) { + return returnValue.catch((e: unknown) => { + const errorMessage = getErrorMessage(e); + + logError(errorMessage, e); + + throw e; + }); + } + + return returnValue; + }; + }, +}); + +export default withErrorLoggingInjectable; diff --git a/packages/core/src/common/utils/with-error-logging/with-error-logging.test.ts b/packages/core/src/common/utils/with-error-logging/with-error-logging.test.ts new file mode 100644 index 0000000000..b1140d4e54 --- /dev/null +++ b/packages/core/src/common/utils/with-error-logging/with-error-logging.test.ts @@ -0,0 +1,240 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import withErrorLoggingInjectable from "./with-error-logging.injectable"; +import { pipeline } from "@ogre-tools/fp"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; +import logErrorInjectable from "../../log-error.injectable"; + +describe("with-error-logging", () => { + describe("given decorated sync function", () => { + let toBeDecorated: jest.Mock; + let decorated: (a: string, b: string) => number | undefined; + let logErrorMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + logErrorMock = jest.fn(); + + di.override(logErrorInjectable, () => logErrorMock); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = jest.fn(); + + decorated = pipeline( + toBeDecorated, + withErrorLoggingFor((error: any) => `some-error-message-for-${error.message}`), + ); + }); + + describe("when function does not throw and returns value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => 42); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(logErrorMock).not.toHaveBeenCalled(); + }); + + it("returns the value", () => { + expect(returnValue).toBe(42); + }); + }); + + describe("when function does not throw and returns no value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => undefined); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(logErrorMock).not.toHaveBeenCalled(); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + + describe("when function throws", () => { + let error: Error; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => { + throw new Error("some-error"); + }); + + try { + decorated("some-parameter", "some-other-parameter"); + } catch (e: any) { + error = e; + } + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("logs the error", () => { + expect(logErrorMock).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("throws", () => { + expect(error.message).toBe("some-error"); + }); + }); + }); + + describe("given decorated async function", () => { + let decorated: (a: string, b: string) => Promise; + let toBeDecorated: AsyncFnMock; + let logErrorMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + logErrorMock = jest.fn(); + + di.override(logErrorInjectable, () => logErrorMock); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = asyncFn(); + + decorated = pipeline( + toBeDecorated, + + withErrorLoggingFor( + (error: any) => + `some-error-message-for-${error.message || error.someProperty}`, + ), + ); + }); + + describe("when called", () => { + let returnValuePromise: Promise; + + beforeEach(() => { + returnValuePromise = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error yet", () => { + expect(logErrorMock).not.toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(returnValuePromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when call rejects with error instance", () => { + beforeEach(() => { + toBeDecorated.reject(new Error("some-error")); + }); + + it("logs the error", async () => { + let error: unknown; + + try { + await returnValuePromise; + } catch (e) { + error = e; + } + + expect(logErrorMock).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("rejects", () => { + return expect(returnValuePromise).rejects.toThrow("some-error"); + }); + }); + + describe("when call rejects with something else than error instance", () => { + let error: unknown; + + beforeEach(async () => { + toBeDecorated.reject({ someProperty: "some-rejection" }); + + try { + await returnValuePromise; + } catch (e) { + error = e; + } + }); + + it("logs the rejection", () => { + expect(logErrorMock).toHaveBeenCalledWith( + "some-error-message-for-some-rejection", + error, + ); + }); + + it("rejects", () => { + return expect(returnValuePromise).rejects.toEqual({ someProperty: "some-rejection" }); + }); + }); + + describe("when call resolves with value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(42); + }); + + it("does not log error", () => { + expect(logErrorMock).not.toHaveBeenCalled(); + }); + + it("resolves with the value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBe(42); + }); + }); + + describe("when call resolves without value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(undefined); + }); + + it("does not log error", () => { + expect(logErrorMock).not.toHaveBeenCalled(); + }); + + it("resolves without value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/packages/core/src/common/utils/with-error-suppression/with-error-suppression.test.ts b/packages/core/src/common/utils/with-error-suppression/with-error-suppression.test.ts new file mode 100644 index 0000000000..12d6a60441 --- /dev/null +++ b/packages/core/src/common/utils/with-error-suppression/with-error-suppression.test.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; +import { withErrorSuppression } from "./with-error-suppression"; + +describe("with-error-suppression", () => { + describe("given decorated sync function", () => { + let toBeDecorated: jest.Mock; + let decorated: (a: string, b: string) => void; + + beforeEach(() => { + toBeDecorated = jest.fn(); + + decorated = withErrorSuppression(toBeDecorated); + }); + + describe("when function does not throw", () => { + let returnValue: void; + + beforeEach(() => { + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + + describe("when function throws", () => { + let returnValue: void; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => { + throw new Error("some-error"); + }); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + }); + + describe("given decorated async function", () => { + let decorated: (a: string, b: string) => Promise | Promise; + let toBeDecorated: AsyncFnMock<(a: string, b: string) => Promise>; + + beforeEach(() => { + toBeDecorated = asyncFn(); + + decorated = withErrorSuppression(toBeDecorated); + }); + + describe("when called", () => { + let returnValuePromise: Promise | Promise; + + beforeEach(() => { + returnValuePromise = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(returnValuePromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when call rejects, resolves with nothing", async () => { + await toBeDecorated.reject(new Error("some-error")); + + const returnValue = await returnValuePromise; + + expect(returnValue).toBeUndefined(); + }); + + it("when call resolves, resolves with the value", async () => { + await toBeDecorated.resolve(42); + + const returnValue = await returnValuePromise; + + expect(returnValue).toBe(42); + }); + }); + }); +}); diff --git a/packages/core/src/common/utils/with-error-suppression/with-error-suppression.ts b/packages/core/src/common/utils/with-error-suppression/with-error-suppression.ts new file mode 100644 index 0000000000..657ed13c16 --- /dev/null +++ b/packages/core/src/common/utils/with-error-suppression/with-error-suppression.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { noop } from "lodash/fp"; + +export function withErrorSuppression Promise>(toBeDecorated: TDecorated): (...args: Parameters) => ReturnType | Promise; +export function withErrorSuppression any>(toBeDecorated: TDecorated): (...args: Parameters) => ReturnType | void; + +export function withErrorSuppression(toBeDecorated: any) { + return (...args: any[]) => { + try { + const returnValue = toBeDecorated(...args); + + if (isPromise(returnValue)) { + return returnValue.catch(noop); + } + + return returnValue; + } catch (e) { + return undefined; + } + }; +} + +function isPromise(reference: any): reference is Promise { + return !!reference?.then; +} diff --git a/packages/core/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts b/packages/core/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts new file mode 100644 index 0000000000..42e6cb9a61 --- /dev/null +++ b/packages/core/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import withErrorLoggingInjectable from "../with-error-logging/with-error-logging.injectable"; +import { withErrorSuppression } from "../with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; + +const withOrphanPromiseInjectable = getInjectable({ + id: "with-orphan-promise", + + instantiate: (di) => { + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return Promise>(toBeDecorated: T) => + (...args: Parameters): void => { + const decorated = pipeline( + toBeDecorated, + withErrorLoggingFor(() => "Orphan promise rejection encountered"), + withErrorSuppression, + ); + + decorated(...args); + }; + }, +}); + +export default withOrphanPromiseInjectable; diff --git a/packages/core/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts b/packages/core/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts new file mode 100644 index 0000000000..51ebc18e13 --- /dev/null +++ b/packages/core/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import withOrphanPromiseInjectable from "./with-orphan-promise.injectable"; +import logErrorInjectable from "../../log-error.injectable"; + +describe("with orphan promise, when called", () => { + let toBeDecorated: AsyncFnMock<(arg1: string, arg2: string) => Promise>; + let actual: void; + let logErrorMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + logErrorMock = jest.fn(); + + di.override(logErrorInjectable, () => logErrorMock); + + const withOrphanPromise = di.inject(withOrphanPromiseInjectable); + + toBeDecorated = asyncFn(); + + const decorated = withOrphanPromise(toBeDecorated); + + actual = decorated("some-argument", "some-other-argument"); + }); + + it("calls decorated with arguments", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-argument", "some-other-argument"); + }); + + it("given promise returned by decorated has not been fulfilled yet, already returns nothing", () => { + expect(actual).toBeUndefined(); + }); + + it("when decorated function resolves, nothing happens", async () => { + await toBeDecorated.resolve("irrelevant"); + // Note: there is no expect, test is here only for documentation. + }); + + describe("when decorated function rejects", () => { + beforeEach(async () => { + await toBeDecorated.reject("some-error"); + }); + + it("logs the rejection", () => { + expect(logErrorMock).toHaveBeenCalledWith("Orphan promise rejection encountered", "some-error"); + }); + + it("nothing else happens", () => { + // Note: there is no expect, test is here only for documentation. + }); + }); +}); diff --git a/packages/core/src/common/vars.ts b/packages/core/src/common/vars.ts new file mode 100644 index 0000000000..8f53d6354c --- /dev/null +++ b/packages/core/src/common/vars.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// App's common configuration for any process (main, renderer, build pipeline, etc.) +import type { ThemeId } from "../renderer/themes/lens-theme"; + +export const publicPath = "/build/" as string; +export const defaultThemeId: ThemeId = "lens-dark"; +export const defaultFontSize = 12; +export const defaultTerminalFontFamily = "RobotoMono"; +export const defaultEditorFontFamily = "RobotoMono"; + +// Apis +export const apiPrefix = "/api"; // local router apis +export const apiKubePrefix = "/api-kube"; // k8s cluster apis + +// Links +export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string; +export const slackUrl = "https://k8slens.dev/slack.html" as string; +export const supportUrl = "https://docs.k8slens.dev/support/" as string; +export const docsUrl = "https://docs.k8slens.dev" as string; diff --git a/packages/core/src/common/vars/app-name.injectable.ts b/packages/core/src/common/vars/app-name.injectable.ts new file mode 100644 index 0000000000..4d6d87421c --- /dev/null +++ b/packages/core/src/common/vars/app-name.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import isDevelopmentInjectable from "./is-development.injectable"; +import productNameInjectable from "./product-name.injectable"; + +const appNameInjectable = getInjectable({ + id: "app-name", + + instantiate: (di) => { + const isDevelopment = di.inject(isDevelopmentInjectable); + const productName = di.inject(productNameInjectable); + + return `${productName}${isDevelopment ? "Dev" : ""}`; + }, +}); + +export default appNameInjectable; diff --git a/packages/core/src/common/vars/application-copyright.injectable.ts b/packages/core/src/common/vars/application-copyright.injectable.ts new file mode 100644 index 0000000000..0233584225 --- /dev/null +++ b/packages/core/src/common/vars/application-copyright.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "./application-information-token"; + +const applicationCopyrightInjectable = getInjectable({ + id: "application-copyright", + instantiate: (di) => di.inject(applicationInformationToken).copyright, +}); + +export default applicationCopyrightInjectable; diff --git a/packages/core/src/common/vars/application-description.injectable.ts b/packages/core/src/common/vars/application-description.injectable.ts new file mode 100644 index 0000000000..5d63a67aa5 --- /dev/null +++ b/packages/core/src/common/vars/application-description.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "./application-information-token"; + +const applicationDescriptionInjectable = getInjectable({ + id: "application-description", + instantiate: (di) => di.inject(applicationInformationToken).description, +}); + +export default applicationDescriptionInjectable; diff --git a/packages/core/src/common/vars/application-information-injectable.ts b/packages/core/src/common/vars/application-information-injectable.ts new file mode 100644 index 0000000000..a73e311233 --- /dev/null +++ b/packages/core/src/common/vars/application-information-injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import packageJson from "../../../package.json"; +import applicationInformationToken from "../../common/vars/application-information-token"; + +const applicationInformationInjectable = getInjectable({ + id: "application-information", + injectionToken: applicationInformationToken, + instantiate: () => { + const { version, config, productName, build, copyright, description, name } = packageJson; + + return { version, config, productName, build, copyright, description, name }; + }, + causesSideEffects: true, +}); + +export default applicationInformationInjectable; diff --git a/packages/core/src/common/vars/application-information-token.ts b/packages/core/src/common/vars/application-information-token.ts new file mode 100644 index 0000000000..dcd56a3146 --- /dev/null +++ b/packages/core/src/common/vars/application-information-token.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type packageJson from "../../../package.json"; + +export type ApplicationInformation = Pick & { + build: Partial & { publish?: unknown[] }; +}; + +const applicationInformationToken = getInjectionToken({ + id: "application-information-token", +}); + +export default applicationInformationToken; diff --git a/packages/core/src/common/vars/application-information.global-override-for-injectable.ts b/packages/core/src/common/vars/application-information.global-override-for-injectable.ts new file mode 100644 index 0000000000..e0b886fa65 --- /dev/null +++ b/packages/core/src/common/vars/application-information.global-override-for-injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import applicationInformationInjectable from "./application-information-injectable"; + +export default getGlobalOverride(applicationInformationInjectable, () => ({ + name: "some-product-name", + productName: "some-product-name", + version: "6.0.0", + build: {} as any, + config: { + k8sProxyVersion: "0.2.1", + bundledKubectlVersion: "1.23.3", + bundledHelmVersion: "3.7.2", + sentryDsn: "", + contentSecurityPolicy: "script-src 'unsafe-eval' 'self'; frame-src http://*.localhost:*/; img-src * data:", + welcomeRoute: "/welcome", + }, + copyright: "some-copyright-information", + description: "some-descriptive-text", +})); diff --git a/packages/core/src/common/vars/base-bundled-binaries-dir.injectable.ts b/packages/core/src/common/vars/base-bundled-binaries-dir.injectable.ts new file mode 100644 index 0000000000..968ccc5c5b --- /dev/null +++ b/packages/core/src/common/vars/base-bundled-binaries-dir.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import bundledResourcesDirectoryInjectable from "./bundled-resources-dir.injectable"; +import normalizedPlatformArchitectureInjectable from "./normalized-platform-architecture.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; + +const baseBundledBinariesDirectoryInjectable = getInjectable({ + id: "base-bundled-binaries-directory", + instantiate: (di) => { + const bundledResourcesDirectory = di.inject(bundledResourcesDirectoryInjectable); + const normalizedPlatformArchitecture = di.inject(normalizedPlatformArchitectureInjectable); + const joinPaths = di.inject(joinPathsInjectable); + + return joinPaths( + bundledResourcesDirectory, + normalizedPlatformArchitecture, + ); + }, +}); + +export default baseBundledBinariesDirectoryInjectable; diff --git a/packages/core/src/common/vars/build-semantic-version.injectable.ts b/packages/core/src/common/vars/build-semantic-version.injectable.ts new file mode 100644 index 0000000000..2a49327480 --- /dev/null +++ b/packages/core/src/common/vars/build-semantic-version.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import { SemVer } from "semver"; +import type { InitializableState } from "../initializable-state/create"; +import { createInitializableState } from "../initializable-state/create"; +import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token"; + +export const buildVersionInjectionToken = getInjectionToken>({ + id: "build-version-token", +}); + +export const buildVersionChannel: RequestChannel = { + id: "build-version", +}; + +const buildSemanticVersionInjectable = createInitializableState({ + id: "build-semantic-version", + init: (di) => { + const buildVersion = di.inject(buildVersionInjectionToken); + + return new SemVer(buildVersion.get()); + }, +}); + +export default buildSemanticVersionInjectable; + diff --git a/packages/core/src/common/vars/bundled-kubectl-version.injectable.ts b/packages/core/src/common/vars/bundled-kubectl-version.injectable.ts new file mode 100644 index 0000000000..f5817426fb --- /dev/null +++ b/packages/core/src/common/vars/bundled-kubectl-version.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "./application-information-token"; + +const bundledKubectlVersionInjectable = getInjectable({ + id: "bundled-kubectl-version", + instantiate: (di) => di.inject(applicationInformationToken).config.bundledKubectlVersion, +}); + +export default bundledKubectlVersionInjectable; diff --git a/packages/core/src/common/vars/bundled-resources-dir.injectable.ts b/packages/core/src/common/vars/bundled-resources-dir.injectable.ts new file mode 100644 index 0000000000..2058b1d5d3 --- /dev/null +++ b/packages/core/src/common/vars/bundled-resources-dir.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import isProductionInjectable from "./is-production.injectable"; +import normalizedPlatformInjectable from "./normalized-platform.injectable"; +import lensResourcesDirInjectable from "./lens-resources-dir.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; + +const bundledResourcesDirectoryInjectable = getInjectable({ + id: "bundled-resources-directory", + instantiate: (di) => { + const isProduction = di.inject(isProductionInjectable); + const normalizedPlatform = di.inject(normalizedPlatformInjectable); + const joinPaths = di.inject(joinPathsInjectable); + const lensResourcesDir = di.inject(lensResourcesDirInjectable); + + return isProduction + ? lensResourcesDir + : joinPaths(lensResourcesDir, "binaries", "client", normalizedPlatform); + }, +}); + +export default bundledResourcesDirectoryInjectable; diff --git a/packages/core/src/common/vars/content-security-policy.injectable.ts b/packages/core/src/common/vars/content-security-policy.injectable.ts new file mode 100644 index 0000000000..01ccee0980 --- /dev/null +++ b/packages/core/src/common/vars/content-security-policy.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "./application-information-token"; + +const contentSecurityPolicyInjectable = getInjectable({ + id: "content-security-policy", + instantiate: (di) => di.inject(applicationInformationToken).config.contentSecurityPolicy, +}); + +export default contentSecurityPolicyInjectable; diff --git a/packages/core/src/common/vars/extension-api-version.injectable.ts b/packages/core/src/common/vars/extension-api-version.injectable.ts new file mode 100644 index 0000000000..9b84cf1177 --- /dev/null +++ b/packages/core/src/common/vars/extension-api-version.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { SemVer } from "semver"; +import applicationInformationToken from "./application-information-token"; + +const extensionApiVersionInjectable = getInjectable({ + id: "extension-api-version", + instantiate: (di) => { + const { major, minor, patch } = new SemVer(di.inject(applicationInformationToken).version); + + return `${major}.${minor}.${patch}`; + }, +}); + +export default extensionApiVersionInjectable; diff --git a/packages/core/src/common/vars/is-debugging.global-override-for-injectable.ts b/packages/core/src/common/vars/is-debugging.global-override-for-injectable.ts new file mode 100644 index 0000000000..7aa500ff2e --- /dev/null +++ b/packages/core/src/common/vars/is-debugging.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import isDebuggingInjectable from "./is-debugging.injectable"; + +export default getGlobalOverride(isDebuggingInjectable, () => false); diff --git a/packages/core/src/common/vars/is-debugging.injectable.ts b/packages/core/src/common/vars/is-debugging.injectable.ts new file mode 100644 index 0000000000..079086e628 --- /dev/null +++ b/packages/core/src/common/vars/is-debugging.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const isDebuggingInjectable = getInjectable({ + id: "is-debugging", + instantiate: () => ["true", "1", "yes", "y", "on"].includes((process.env.DEBUG ?? "").toLowerCase()), + causesSideEffects: true, +}); + +export default isDebuggingInjectable; diff --git a/packages/core/src/common/vars/is-development.injectable.ts b/packages/core/src/common/vars/is-development.injectable.ts new file mode 100644 index 0000000000..af7aeb3b91 --- /dev/null +++ b/packages/core/src/common/vars/is-development.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import isProductionInjectable from "./is-production.injectable"; + +const isDevelopmentInjectable = getInjectable({ + id: "is-development", + instantiate: (di) => !di.inject(isProductionInjectable), +}); + +export default isDevelopmentInjectable; diff --git a/packages/core/src/common/vars/is-integration-testing.injectable.ts b/packages/core/src/common/vars/is-integration-testing.injectable.ts new file mode 100644 index 0000000000..7d6d5ce24e --- /dev/null +++ b/packages/core/src/common/vars/is-integration-testing.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import commandLineArgumentsInjectable from "../../main/utils/command-line-arguments.injectable"; + +const isIntegrationTestingInjectable = getInjectable({ + id: "is-integration-testing", + + instantiate: (di) => { + const commandLineArguments = di.inject(commandLineArgumentsInjectable); + + return commandLineArguments.includes("--integration-testing"); + }, +}); + +export default isIntegrationTestingInjectable; diff --git a/packages/core/src/common/vars/is-linux.injectable.ts b/packages/core/src/common/vars/is-linux.injectable.ts new file mode 100644 index 0000000000..d84165fad5 --- /dev/null +++ b/packages/core/src/common/vars/is-linux.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import platformInjectable from "./platform.injectable"; + +const isLinuxInjectable = getInjectable({ + id: "is-linux", + + instantiate: (di) => { + const platform = di.inject(platformInjectable); + + return platform === "linux"; + }, +}); + +export default isLinuxInjectable; diff --git a/packages/core/src/common/vars/is-mac.injectable.ts b/packages/core/src/common/vars/is-mac.injectable.ts new file mode 100644 index 0000000000..67a6fda286 --- /dev/null +++ b/packages/core/src/common/vars/is-mac.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import platformInjectable from "./platform.injectable"; + +const isMacInjectable = getInjectable({ + id: "is-mac", + + instantiate: (di) => { + const platform = di.inject(platformInjectable); + + return platform === "darwin"; + }, +}); + +export default isMacInjectable; diff --git a/packages/core/src/common/vars/is-production.injectable.ts b/packages/core/src/common/vars/is-production.injectable.ts new file mode 100644 index 0000000000..661cb397d1 --- /dev/null +++ b/packages/core/src/common/vars/is-production.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import nodeEnvInjectionToken from "./node-env-injection-token"; + +const isProductionInjectable = getInjectable({ + id: "is-production", + + instantiate: (di) => { + const nodeEnv = di.inject(nodeEnvInjectionToken); + + return nodeEnv === "production"; + }, +}); + +export default isProductionInjectable; diff --git a/packages/core/src/common/vars/is-snap-package.global-override-for-injectable.ts b/packages/core/src/common/vars/is-snap-package.global-override-for-injectable.ts new file mode 100644 index 0000000000..cb3ff0a6e9 --- /dev/null +++ b/packages/core/src/common/vars/is-snap-package.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import isSnapPackageInjectable from "./is-snap-package.injectable"; + +export default getGlobalOverride(isSnapPackageInjectable, () => false); diff --git a/packages/core/src/common/vars/is-snap-package.injectable.ts b/packages/core/src/common/vars/is-snap-package.injectable.ts new file mode 100644 index 0000000000..a2c545870b --- /dev/null +++ b/packages/core/src/common/vars/is-snap-package.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const isSnapPackageInjectable = getInjectable({ + id: "is-snap", + instantiate: () => Boolean(process.env.SNAP), + causesSideEffects: true, +}); + +export default isSnapPackageInjectable; diff --git a/packages/core/src/common/vars/is-windows.injectable.ts b/packages/core/src/common/vars/is-windows.injectable.ts new file mode 100644 index 0000000000..8eb78dcb58 --- /dev/null +++ b/packages/core/src/common/vars/is-windows.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import platformInjectable from "./platform.injectable"; + +const isWindowsInjectable = getInjectable({ + id: "is-windows", + + instantiate: (di) => { + const platform = di.inject(platformInjectable); + + return platform === "win32"; + }, +}); + +export default isWindowsInjectable; diff --git a/packages/core/src/common/vars/lens-resources-dir.global-override-for-injectable.ts b/packages/core/src/common/vars/lens-resources-dir.global-override-for-injectable.ts new file mode 100644 index 0000000000..1a72b0ccf7 --- /dev/null +++ b/packages/core/src/common/vars/lens-resources-dir.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import lensResourcesDirInjectable from "./lens-resources-dir.injectable"; + +export default getGlobalOverride(lensResourcesDirInjectable, () => "/irrelavent-dir-for-lens-resources"); diff --git a/packages/core/src/common/vars/lens-resources-dir.injectable.ts b/packages/core/src/common/vars/lens-resources-dir.injectable.ts new file mode 100644 index 0000000000..9aebba19b3 --- /dev/null +++ b/packages/core/src/common/vars/lens-resources-dir.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import isProductionInjectable from "./is-production.injectable"; + +const lensResourcesDirInjectable = getInjectable({ + id: "lens-resources-dir", + + instantiate: (di) => { + const isProduction = di.inject(isProductionInjectable); + + return isProduction + ? process.resourcesPath + : process.cwd(); + }, + + causesSideEffects: true, +}); + +export default lensResourcesDirInjectable; diff --git a/packages/core/src/common/vars/node-env-injection-token.ts b/packages/core/src/common/vars/node-env-injection-token.ts new file mode 100644 index 0000000000..9de463c1cb --- /dev/null +++ b/packages/core/src/common/vars/node-env-injection-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; + +const nodeEnvInjectionToken = getInjectionToken({ + id: "node-env-injection-token", +}); + +export default nodeEnvInjectionToken; diff --git a/packages/core/src/common/vars/normalized-platform-architecture.injectable.ts b/packages/core/src/common/vars/normalized-platform-architecture.injectable.ts new file mode 100644 index 0000000000..6b98856268 --- /dev/null +++ b/packages/core/src/common/vars/normalized-platform-architecture.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import processArchInjectable from "./process-arch.injectable"; + +const normalizedPlatformArchitectureInjectable = getInjectable({ + id: "normalized-platform-architecture", + instantiate: (di) => { + const platformArch = di.inject(processArchInjectable); + + switch (platformArch) { + case "arm64": + return "arm64"; + case "x64": + case "amd64": + return "x64"; + case "386": + case "x32": + case "ia32": + return "ia32"; + default: + throw new Error(`arch=${platformArch} is unsupported`); + } + }, +}); + +export default normalizedPlatformArchitectureInjectable; diff --git a/packages/core/src/common/vars/normalized-platform.injectable.ts b/packages/core/src/common/vars/normalized-platform.injectable.ts new file mode 100644 index 0000000000..ee1bf7fb74 --- /dev/null +++ b/packages/core/src/common/vars/normalized-platform.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import platformInjectable from "./platform.injectable"; + +export type NormalizedPlatform = "darwin" | "linux" | "windows"; + +const normalizedPlatformInjectable = getInjectable({ + id: "normalized-platform", + + instantiate: (di): NormalizedPlatform => { + const platform = di.inject(platformInjectable); + + switch (platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + throw new Error(`platform=${platform} is unsupported`); + } + }, +}); + +export default normalizedPlatformInjectable; diff --git a/packages/core/src/common/vars/platform.global-override-for-injectable.ts b/packages/core/src/common/vars/platform.global-override-for-injectable.ts new file mode 100644 index 0000000000..4bb06dec5e --- /dev/null +++ b/packages/core/src/common/vars/platform.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import platformInjectable from "./platform.injectable"; + +export default getGlobalOverride(platformInjectable, () => "darwin"); diff --git a/packages/core/src/common/vars/platform.injectable.ts b/packages/core/src/common/vars/platform.injectable.ts new file mode 100644 index 0000000000..00d6e42aca --- /dev/null +++ b/packages/core/src/common/vars/platform.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +export const allPlatforms = ["win32", "darwin", "linux"] as const; + +const platformInjectable = getInjectable({ + id: "platform", + instantiate: () => process.platform as typeof allPlatforms[number], + causesSideEffects: true, +}); + +export default platformInjectable; diff --git a/packages/core/src/common/vars/process-arch.global-override-for-injectable.ts b/packages/core/src/common/vars/process-arch.global-override-for-injectable.ts new file mode 100644 index 0000000000..42d74d4ec8 --- /dev/null +++ b/packages/core/src/common/vars/process-arch.global-override-for-injectable.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getGlobalOverride } from "../test-utils/get-global-override"; +import processArchInjectable from "./process-arch.injectable"; + +export default getGlobalOverride(processArchInjectable, () => "x64"); diff --git a/packages/core/src/common/vars/process-arch.injectable.ts b/packages/core/src/common/vars/process-arch.injectable.ts new file mode 100644 index 0000000000..5504855341 --- /dev/null +++ b/packages/core/src/common/vars/process-arch.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; + +const processArchInjectable = getInjectable({ + id: "process-arch", + instantiate: () => process.arch, + causesSideEffects: true, +}); + +export default processArchInjectable; diff --git a/packages/core/src/common/vars/product-name.injectable.ts b/packages/core/src/common/vars/product-name.injectable.ts new file mode 100644 index 0000000000..7a5ba73f2d --- /dev/null +++ b/packages/core/src/common/vars/product-name.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "./application-information-token"; + +const productNameInjectable = getInjectable({ + id: "product-name", + instantiate: (di) => di.inject(applicationInformationToken).productName, +}); + +export default productNameInjectable; diff --git a/packages/core/src/common/vars/release-channel.injectable.ts b/packages/core/src/common/vars/release-channel.injectable.ts new file mode 100644 index 0000000000..6554fbc0ac --- /dev/null +++ b/packages/core/src/common/vars/release-channel.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { createInitializableState } from "../initializable-state/create"; +import buildSemanticVersionInjectable from "./build-semantic-version.injectable"; +import type { ReleaseChannel } from "../../features/application-update/common/update-channels"; + +const releaseChannelInjectable = createInitializableState({ + id: "release-channel", + init: (di): ReleaseChannel => { + const buildSemanticVersion = di.inject(buildSemanticVersionInjectable); + const currentReleaseChannel = buildSemanticVersion.get().prerelease[0]; + + switch (currentReleaseChannel) { + case "latest": + case "beta": + case "alpha": + return currentReleaseChannel; + default: + return "latest"; + } + }, +}); + +export default releaseChannelInjectable; diff --git a/packages/core/src/common/vars/sentry-dsn-url.injectable.ts b/packages/core/src/common/vars/sentry-dsn-url.injectable.ts new file mode 100644 index 0000000000..7fd138ab0a --- /dev/null +++ b/packages/core/src/common/vars/sentry-dsn-url.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "./application-information-token"; + +const sentryDataSourceNameInjectable = getInjectable({ + id: "sentry-data-source-name", + instantiate: (di) => di.inject(applicationInformationToken).config.sentryDsn, +}); + +export default sentryDataSourceNameInjectable; diff --git a/packages/core/src/common/vars/static-files-directory.global-override-for-injectable.ts b/packages/core/src/common/vars/static-files-directory.global-override-for-injectable.ts new file mode 100644 index 0000000000..3b8ec43046 --- /dev/null +++ b/packages/core/src/common/vars/static-files-directory.global-override-for-injectable.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getGlobalOverride } from "../test-utils/get-global-override"; +import staticFilesDirectoryInjectable from "./static-files-directory.injectable"; + +export default getGlobalOverride(staticFilesDirectoryInjectable, () => "/some-static-directory"); diff --git a/packages/core/src/common/vars/static-files-directory.injectable.ts b/packages/core/src/common/vars/static-files-directory.injectable.ts new file mode 100644 index 0000000000..8ed9967119 --- /dev/null +++ b/packages/core/src/common/vars/static-files-directory.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import appPathsInjectable from "../app-paths/app-paths.injectable"; +import joinPathsInjectable from "../path/join-paths.injectable"; + +const staticFilesDirectoryInjectable = getInjectable({ + id: "static-files-directory", + + instantiate: (di) => { + const joinPaths = di.inject(joinPathsInjectable); + const currentAppDir = di.inject(appPathsInjectable).currentApp; + + return joinPaths(currentAppDir, "static"); + }, +}); + +export default staticFilesDirectoryInjectable; diff --git a/packages/core/src/common/vars/store-migration-version.injectable.ts b/packages/core/src/common/vars/store-migration-version.injectable.ts new file mode 100644 index 0000000000..eb2b7aa8cc --- /dev/null +++ b/packages/core/src/common/vars/store-migration-version.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationInformationToken from "./application-information-token"; + +const storeMigrationVersionInjectable = getInjectable({ + id: "store-migration-version", + instantiate: (di) => di.inject(applicationInformationToken).version, +}); + +export default storeMigrationVersionInjectable; diff --git a/packages/core/src/common/weblinks-store/migration-token.ts b/packages/core/src/common/weblinks-store/migration-token.ts new file mode 100644 index 0000000000..d1cea9334b --- /dev/null +++ b/packages/core/src/common/weblinks-store/migration-token.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MigrationDeclaration } from "../base-store/migrations.injectable"; + +export const weblinkStoreMigrationInjectionToken = getInjectionToken({ + id: "weblink-store-migration-token", +}); diff --git a/packages/core/src/common/weblinks-store/weblink-store.injectable.ts b/packages/core/src/common/weblinks-store/weblink-store.injectable.ts new file mode 100644 index 0000000000..cf793a2e58 --- /dev/null +++ b/packages/core/src/common/weblinks-store/weblink-store.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { baseStoreIpcChannelPrefixesInjectionToken } from "../base-store/channel-prefix"; +import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../base-store/disable-sync"; +import storeMigrationsInjectable from "../base-store/migrations.injectable"; +import { persistStateToConfigInjectionToken } from "../base-store/save-to-file"; +import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; +import loggerInjectable from "../logger.injectable"; +import getBasenameOfPathInjectable from "../path/get-basename.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../utils/channel/enlist-message-channel-listener-injection-token"; +import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; +import { weblinkStoreMigrationInjectionToken } from "./migration-token"; +import { WeblinkStore } from "./weblink-store"; + +const weblinkStoreInjectable = getInjectable({ + id: "weblink-store", + instantiate: (di) => new WeblinkStore({ + directoryForUserData: di.inject(directoryForUserDataInjectable), + getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable), + logger: di.inject(loggerInjectable), + storeMigrationVersion: di.inject(storeMigrationVersionInjectable), + migrations: di.inject(storeMigrationsInjectable, weblinkStoreMigrationInjectionToken), + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken), + persistStateToConfig: di.inject(persistStateToConfigInjectionToken), + enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken), + shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), + }), +}); + +export default weblinkStoreInjectable; diff --git a/packages/core/src/common/weblinks-store/weblink-store.ts b/packages/core/src/common/weblinks-store/weblink-store.ts new file mode 100644 index 0000000000..2044ca08c1 --- /dev/null +++ b/packages/core/src/common/weblinks-store/weblink-store.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, comparer, observable, makeObservable } from "mobx"; +import type { BaseStoreDependencies } from "../base-store/base-store"; +import { BaseStore } from "../base-store/base-store"; +import * as uuid from "uuid"; +import { toJS } from "../utils"; + +export interface WeblinkData { + id: string; + name: string; + url: string; +} + +export interface WeblinkCreateOptions { + id?: string; + name: string; + url: string; +} + + +export interface WeblinkStoreModel { + weblinks: WeblinkData[]; +} + +export class WeblinkStore extends BaseStore { + @observable weblinks: WeblinkData[] = []; + + constructor(deps: BaseStoreDependencies) { + super(deps, { + configName: "lens-weblink-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, + }); + makeObservable(this); + this.load(); + } + + @action + protected fromStore(data: Partial = {}) { + this.weblinks = data.weblinks || []; + } + + add(data: WeblinkCreateOptions) { + const { + id = uuid.v4(), + name, + url, + } = data; + const weblink: WeblinkData = { id, name, url }; + + this.weblinks.push(weblink); + + return weblink; + } + + @action + removeById(id: string) { + this.weblinks = this.weblinks.filter((w) => w.id !== id); + } + + toJSON(): WeblinkStoreModel { + const model: WeblinkStoreModel = { + weblinks: this.weblinks, + }; + + return toJS(model); + } +} diff --git a/packages/core/src/extensions/__tests__/extension-loader.test.ts b/packages/core/src/extensions/__tests__/extension-loader.test.ts new file mode 100644 index 0000000000..1c010f7640 --- /dev/null +++ b/packages/core/src/extensions/__tests__/extension-loader.test.ts @@ -0,0 +1,174 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ExtensionLoader } from "../extension-loader"; +import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; +import { runInAction } from "mobx"; +import updateExtensionsStateInjectable from "../extension-loader/update-extensions-state/update-extensions-state.injectable"; +import { delay } from "../../renderer/utils"; +import { getDiForUnitTesting } from "../../renderer/getDiForUnitTesting"; +import ipcRendererInjectable from "../../renderer/utils/channel/ipc-renderer.injectable"; +import type { IpcRenderer } from "electron"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import currentlyInClusterFrameInjectable from "../../renderer/routes/currently-in-cluster-frame.injectable"; + +const manifestPath = "manifest/path"; +const manifestPath2 = "manifest/path2"; +const manifestPath3 = "manifest/path3"; + +describe("ExtensionLoader", () => { + let extensionLoader: ExtensionLoader; + let updateExtensionStateMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + di.override(currentlyInClusterFrameInjectable, () => false); + + di.override(ipcRendererInjectable, () => ({ + invoke: jest.fn(async (channel: string) => { + if (channel === "extension-loader:main:state") { + return [ + [ + manifestPath, + { + manifest: { + name: "TestExtension", + version: "1.0.0", + }, + id: manifestPath, + absolutePath: "/test/1", + manifestPath, + isBundled: false, + isEnabled: true, + }, + ], + [ + manifestPath2, + { + manifest: { + name: "TestExtension2", + version: "2.0.0", + }, + id: manifestPath2, + absolutePath: "/test/2", + manifestPath: manifestPath2, + isBundled: false, + isEnabled: true, + }, + ], + ]; + } + + return []; + }), + + on: (channel: string, listener: (event: any, ...args: any[]) => void) => { + if (channel === "extension-loader:main:state") { + // First initialize with extensions 1 and 2 + // and then broadcast event to remove extension 2 and add extension number 3 + setTimeout(() => { + listener({}, [ + [ + manifestPath, + { + manifest: { + name: "TestExtension", + version: "1.0.0", + }, + id: manifestPath, + absolutePath: "/test/1", + manifestPath, + isBundled: false, + isEnabled: true, + }, + ], + [ + manifestPath3, + { + manifest: { + name: "TestExtension3", + version: "3.0.0", + }, + id: manifestPath3, + absolutePath: "/test/3", + manifestPath: manifestPath3, + isBundled: false, + isEnabled: true, + }, + ], + ]); + }, 10); + } + }, + }) as unknown as IpcRenderer); + + updateExtensionStateMock = jest.fn(); + + di.override(updateExtensionsStateInjectable, () => updateExtensionStateMock); + + extensionLoader = di.inject(extensionLoaderInjectable); + }); + + it("renderer updates extension after ipc broadcast", async () => { + expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`Map {}`); + + await extensionLoader.init(); + await delay(10); + + // Assert the extensions after the extension broadcast event + expect(extensionLoader.userExtensions).toMatchInlineSnapshot(` + Map { + "manifest/path" => Object { + "absolutePath": "/test/1", + "id": "manifest/path", + "isBundled": false, + "isEnabled": true, + "manifest": Object { + "name": "TestExtension", + "version": "1.0.0", + }, + "manifestPath": "manifest/path", + }, + "manifest/path3" => Object { + "absolutePath": "/test/3", + "id": "manifest/path3", + "isBundled": false, + "isEnabled": true, + "manifest": Object { + "name": "TestExtension3", + "version": "3.0.0", + }, + "manifestPath": "manifest/path3", + }, + } + `); + }); + + it("updates ExtensionsStore after isEnabled is changed", async () => { + await extensionLoader.init(); + + expect(updateExtensionStateMock).not.toHaveBeenCalled(); + + runInAction(() => { + extensionLoader.setIsEnabled("manifest/path", false); + }); + + expect(updateExtensionStateMock).toHaveBeenCalledWith( + expect.objectContaining({ + "manifest/path": { + enabled: false, + name: "TestExtension", + }, + + "manifest/path2": { + enabled: true, + name: "TestExtension2", + }, + }), + ); + }); +}); diff --git a/packages/core/src/extensions/__tests__/is-compatible-extension.test.ts b/packages/core/src/extensions/__tests__/is-compatible-extension.test.ts new file mode 100644 index 0000000000..f2ca144681 --- /dev/null +++ b/packages/core/src/extensions/__tests__/is-compatible-extension.test.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { isCompatibleExtension } from "../extension-discovery/is-compatible-extension/is-compatible-extension"; +import type { LensExtensionManifest } from "../lens-extension"; + +describe("Extension/App versions compatibility checks", () => { + it("is compatible with exact version matching", () => { + expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.5.0" })).toBeTruthy(); + }); + + it("is compatible with upper %PATCH versions of base app", () => { + expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.5.5" })).toBeTruthy(); + }); + + it("is compatible with higher %MINOR version of base app", () => { + expect(isCompatible({ extLensEngineVersion: "5.5.0", extensionApiVersion: "5.6.0" })).toBeTruthy(); + }); + + it("is not compatible with higher %MAJOR version of base app", () => { + expect(isCompatible({ extLensEngineVersion: "5.6.0", extensionApiVersion: "6.0.0" })).toBeFalsy(); // extension for lens@5 not compatible with lens@6 + expect(isCompatible({ extLensEngineVersion: "6.0.0", extensionApiVersion: "5.6.0" })).toBeFalsy(); + }); + + it("supports short version format for manifest.engines.lens", () => { + expect(isCompatible({ extLensEngineVersion: "5.5", extensionApiVersion: "5.5.1" })).toBeTruthy(); + }); + + it("throws for incorrect or not supported version format", () => { + expect(() => isCompatible({ + extLensEngineVersion: ">=2.0", + extensionApiVersion: "2.0", + })).toThrow(/Invalid format/i); + + expect(() => isCompatible({ + extLensEngineVersion: "~2.0", + extensionApiVersion: "2.0", + })).toThrow(/Invalid format/i); + + expect(() => isCompatible({ + extLensEngineVersion: "*", + extensionApiVersion: "1.0", + })).toThrow(/Invalid format/i); + }); +}); + +function isCompatible({ extLensEngineVersion = "^1.0", extensionApiVersion = "1.0" } = {}): boolean { + const extensionManifestMock = getExtensionManifestMock(extLensEngineVersion); + + return isCompatibleExtension({ extensionApiVersion })(extensionManifestMock); +} + +function getExtensionManifestMock(lensEngine = "1.0"): LensExtensionManifest { + return { + name: "some-extension", + version: "1.0", + engines: { + lens: lensEngine, + }, + }; +} diff --git a/packages/core/src/extensions/__tests__/lens-extension.test.ts b/packages/core/src/extensions/__tests__/lens-extension.test.ts new file mode 100644 index 0000000000..7cb90a548b --- /dev/null +++ b/packages/core/src/extensions/__tests__/lens-extension.test.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { LensExtension } from "../lens-extension"; +import { Console } from "console"; +import { stdout, stderr } from "process"; + +console = new Console(stdout, stderr); + +let ext: LensExtension; + +describe("lens extension", () => { + beforeEach(async () => { + ext = new LensExtension({ + manifest: { + name: "foo-bar", + version: "0.1.1", + engines: { lens: "^5.5.0" }, + }, + id: "/this/is/fake/package.json", + absolutePath: "/absolute/fake/", + manifestPath: "/this/is/fake/package.json", + isBundled: false, + isEnabled: true, + isCompatible: true, + }); + }); + + describe("name", () => { + it("returns name", () => { + expect(ext.name).toBe("foo-bar"); + }); + }); +}); diff --git a/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts new file mode 100644 index 0000000000..e0b4ea3223 --- /dev/null +++ b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; +import type { Inject } from "@ogre-tools/injectable"; + +export const asLegacyGlobalFunctionForExtensionApi = (( + injectableKey, + instantiationParameter, +) => + (...args: any[]) => { + const injected = getLegacyGlobalDiForExtensionApi().inject( + injectableKey, + instantiationParameter, + ) as unknown as (...args: any[]) => any; + + return injected(...args); + }) as Inject; diff --git a/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.test.ts b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.test.ts new file mode 100644 index 0000000000..ebfc77f40e --- /dev/null +++ b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.test.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { + DiContainer, + Injectable } from "@ogre-tools/injectable"; +import { + createContainer, + getInjectable, +} from "@ogre-tools/injectable"; +import { Environments, setLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; +import { asLegacyGlobalObjectForExtensionApiWithModifications } from "./as-legacy-global-object-for-extension-api-with-modifications"; + +describe("asLegacyGlobalObjectForExtensionApiWithModifications", () => { + describe("given legacy global object", () => { + let di: DiContainer; + let someInjectable: Injectable<{ someProperty: string }, unknown, void>; + let actual: { someProperty: string } & { + someModificationProperty: string; + }; + + beforeEach(() => { + di = createContainer("irrelevant"); + + jest.spyOn(di, "inject"); + + setLegacyGlobalDiForExtensionApi(di, Environments.renderer); + + someInjectable = getInjectable({ + id: "some-injectable", + instantiate: () => ({ + someProperty: "some-property-value", + }), + }); + + di.register(someInjectable); + + actual = asLegacyGlobalObjectForExtensionApiWithModifications( + someInjectable, + { someModificationProperty: "some-modification-value" }, + ); + }); + + it("when not accessed, does not inject yet", () => { + expect(di.inject).not.toHaveBeenCalled(); + }); + + describe("when a property of global is accessed, ", () => { + let actualPropertyValue: string; + + beforeEach(() => { + actualPropertyValue = actual.someProperty; + }); + + it("injects the injectable for global", () => { + expect(di.inject).toHaveBeenCalledWith(someInjectable, undefined); + }); + + it("global has property of injectable", () => { + expect(actualPropertyValue).toBe("some-property-value"); + }); + }); + + describe("when a property of modification is accessed, ", () => { + let actualModificationValue: string; + + beforeEach(() => { + actualModificationValue = actual.someModificationProperty; + }); + + it("injects the injectable for global", () => { + expect(di.inject).toHaveBeenCalledWith(someInjectable, undefined); + }); + + it("global has property of modification", () => { + expect(actualModificationValue).toBe("some-modification-value"); + }); + }); + }); +}); diff --git a/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.ts b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.ts new file mode 100644 index 0000000000..a5261d2917 --- /dev/null +++ b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { asLegacyGlobalForExtensionApi } from "./as-legacy-global-object-for-extension-api"; +import type { Injectable } from "@ogre-tools/injectable"; + +/** + * @deprecated use asLegacyGlobalForExtensionApi instead, and use proper implementations instead of "modifications". + */ +export const asLegacyGlobalObjectForExtensionApiWithModifications = < + InjectableInstance extends InjectionTokenInstance & object, + InjectionTokenInstance, + ModificationObject extends object, +>( + injectable: Injectable, + modificationObject: ModificationObject, + ) => + Object.assign( + asLegacyGlobalForExtensionApi(injectable), + modificationObject, + ); diff --git a/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts new file mode 100644 index 0000000000..81d6f94b20 --- /dev/null +++ b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Inject } from "@ogre-tools/injectable"; +import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; + +export const asLegacyGlobalForExtensionApi = (( + injectable, + instantiationParameter, +) => + new Proxy( + {}, + + { + apply(target: any, thisArg, argArray) { + const fn = getLegacyGlobalDiForExtensionApi().inject( + injectable, + instantiationParameter, + ) as unknown as (...args: any[]) => any; + + return fn(...argArray); + }, + + get(target, propertyName) { + if (propertyName === "$$typeof") { + return undefined; + } + + const instance: any = getLegacyGlobalDiForExtensionApi().inject( + injectable, + instantiationParameter, + ); + + const propertyValue = instance[propertyName] ?? target[propertyName]; + + if (typeof propertyValue === "function") { + return function (...args: any[]) { + return propertyValue.apply(instance, args); + }; + } + + return propertyValue; + }, + }, + )) as Inject; diff --git a/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts new file mode 100644 index 0000000000..f9016f045b --- /dev/null +++ b/packages/core/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; +import { asLegacyGlobalForExtensionApi } from "./as-legacy-global-object-for-extension-api"; +import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; +import loggerInjectable from "../../common/logger.injectable"; + +export interface LegacySingleton { + createInstance(): T; + getInstance(): T; + resetInstance(): void; +} + +export function asLegacyGlobalSingletonForExtensionApi(injectable: Injectable): LegacySingleton; +export function asLegacyGlobalSingletonForExtensionApi(injectable: Injectable, param: InstantiationParameter): LegacySingleton; + +export function asLegacyGlobalSingletonForExtensionApi( + injectable: Injectable, + instantiationParameter?: InstantiationParameter, +): LegacySingleton { + const instance = asLegacyGlobalForExtensionApi( + injectable as never, + instantiationParameter, + ) as Instance; + + return { + createInstance: () => instance, + + getInstance: () => instance, + + resetInstance: () => { + const di = getLegacyGlobalDiForExtensionApi(); + const logger = di.inject(loggerInjectable); + + logger.warn( + `resetInstance() for a legacy global singleton of "${injectable.id}" does nothing.`, + ); + }, + }; +} diff --git a/packages/core/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts b/packages/core/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts new file mode 100644 index 0000000000..4d7d9ca8f5 --- /dev/null +++ b/packages/core/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; + +const legacyGlobalDis = new Map(); + +export enum Environments { + renderer, + main, +} + +export const setLegacyGlobalDiForExtensionApi = ( + di: DiContainer, + environment: Environments, +) => { + legacyGlobalDis.set(environment, di); +}; + +export const getLegacyGlobalDiForExtensionApi = () => { + if (legacyGlobalDis.size > 1) { + throw new Error("Tried to get DI container using legacy globals where there is multiple containers available."); + } + + const [di] = [...legacyGlobalDis.values()]; + + if (!di) { + throw new Error("Tried to get DI container using legacy globals where there is no containers available."); + } + + return di; +}; + +export function getEnvironmentSpecificLegacyGlobalDiForExtensionApi(environment: Environments) { + const di = legacyGlobalDis.get(environment); + + if (!di) { + throw new Error("Tried to get DI container using legacy globals in environment which doesn't exist"); + } + + return di; +} diff --git a/packages/core/src/extensions/common-api/app.ts b/packages/core/src/extensions/common-api/app.ts new file mode 100644 index 0000000000..92ecd19ef9 --- /dev/null +++ b/packages/core/src/extensions/common-api/app.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import appNameInjectable from "../../common/vars/app-name.injectable"; +import isLinuxInjectable from "../../common/vars/is-linux.injectable"; +import isMacInjectable from "../../common/vars/is-mac.injectable"; +import isSnapPackageInjectable from "../../common/vars/is-snap-package.injectable"; +import isWindowsInjectable from "../../common/vars/is-windows.injectable"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import getEnabledExtensionsInjectable from "./get-enabled-extensions/get-enabled-extensions.injectable"; +import type { UserPreferenceExtensionItems } from "./user-preferences"; +import { Preferences } from "./user-preferences"; +import { slackUrl, issuesTrackerUrl } from "../../common/vars"; +import { buildVersionInjectionToken } from "../../common/vars/build-semantic-version.injectable"; + +export interface AppExtensionItems { + readonly Preferences: UserPreferenceExtensionItems; + readonly version: string; + readonly appName: string; + readonly slackUrl: string; + readonly issuesTrackerUrl: string; + readonly isSnap: boolean; + readonly isWindows: boolean; + readonly isMac: boolean; + readonly isLinux: boolean; + getEnabledExtensions: () => string[]; +} + +export const App: AppExtensionItems = { + Preferences, + getEnabledExtensions: asLegacyGlobalFunctionForExtensionApi(getEnabledExtensionsInjectable), + get version() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(buildVersionInjectionToken).get(); + }, + get appName() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(appNameInjectable); + }, + get isSnap() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isSnapPackageInjectable); + }, + get isWindows() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isWindowsInjectable); + }, + get isMac() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isMacInjectable); + }, + get isLinux() { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(isLinuxInjectable); + }, + slackUrl, + issuesTrackerUrl, +}; diff --git a/packages/core/src/extensions/common-api/catalog.ts b/packages/core/src/extensions/common-api/catalog.ts new file mode 100644 index 0000000000..b31059d2c1 --- /dev/null +++ b/packages/core/src/extensions/common-api/catalog.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import kubernetesClusterCategoryInjectable from "../../common/catalog/categories/kubernetes-cluster.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +export { + KubernetesCluster, + GeneralEntity, + WebLink, +} from "../../common/catalog-entities"; + +export const kubernetesClusterCategory = asLegacyGlobalForExtensionApi(kubernetesClusterCategoryInjectable); + +export type { + KubernetesClusterPrometheusMetrics, + KubernetesClusterSpec, + KubernetesClusterMetadata, + WebLinkSpec, + WebLinkStatus, + WebLinkStatusPhase, + KubernetesClusterStatusPhase, + KubernetesClusterStatus, +} from "../../common/catalog-entities"; + +export * from "../../common/catalog/catalog-entity"; diff --git a/packages/core/src/extensions/common-api/event-bus.ts b/packages/core/src/extensions/common-api/event-bus.ts new file mode 100644 index 0000000000..d95e3f49d4 --- /dev/null +++ b/packages/core/src/extensions/common-api/event-bus.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import appEventBusInjectable from "../../common/app-event-bus/app-event-bus.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +export type { AppEvent } from "../../common/app-event-bus/event-bus"; + +export const appEventBus = asLegacyGlobalForExtensionApi(appEventBusInjectable); diff --git a/packages/core/src/extensions/common-api/get-enabled-extensions/get-enabled-extensions.injectable.ts b/packages/core/src/extensions/common-api/get-enabled-extensions/get-enabled-extensions.injectable.ts new file mode 100644 index 0000000000..747dfa4f42 --- /dev/null +++ b/packages/core/src/extensions/common-api/get-enabled-extensions/get-enabled-extensions.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import extensionsStoreInjectable from "../../extensions-store/extensions-store.injectable"; + +const getEnabledExtensionsInjectable = getInjectable({ + id: "get-enabled-extensions", + + instantiate: (di) => () => + di.inject(extensionsStoreInjectable).enabledExtensions, +}); + +export default getEnabledExtensionsInjectable; diff --git a/packages/core/src/extensions/common-api/index.ts b/packages/core/src/extensions/common-api/index.ts new file mode 100644 index 0000000000..01e41ca11c --- /dev/null +++ b/packages/core/src/extensions/common-api/index.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// APIs +import { App } from "./app"; +import * as EventBus from "./event-bus"; +import * as Store from "./stores"; +import { Util } from "./utils"; +import * as Catalog from "./catalog"; +import * as Types from "./types"; +import * as Proxy from "./proxy"; +import loggerInjectable from "../../common/logger.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +const logger = asLegacyGlobalForExtensionApi(loggerInjectable); + +export { + App, + EventBus, + Catalog, + Store, + Types, + Util, + logger, + Proxy, +}; diff --git a/packages/core/src/extensions/common-api/k8s-api.ts b/packages/core/src/extensions/common-api/k8s-api.ts new file mode 100644 index 0000000000..3856429717 --- /dev/null +++ b/packages/core/src/extensions/common-api/k8s-api.ts @@ -0,0 +1,240 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// NOTE: this file is not currently exported as part of `Common`, but should be. +// It is here to consolidate the common parts which are exported to `Main` +// and to `Renderer` + +import apiManagerInjectable from "../../common/k8s-api/api-manager/manager.injectable"; +import createKubeApiForClusterInjectable from "../../common/k8s-api/create-kube-api-for-cluster.injectable"; +import createKubeApiForRemoteClusterInjectable from "../../common/k8s-api/create-kube-api-for-remote-cluster.injectable"; +import createResourceStackInjectable from "../../common/k8s/create-resource-stack.injectable"; +import type { ResourceApplyingStack } from "../../common/k8s/resource-stack"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import type { KubernetesCluster } from "./catalog"; +import type { KubeApiDataFrom, KubeObjectStoreOptions } from "../../common/k8s-api/kube-object.store"; +import { KubeObjectStore as InternalKubeObjectStore } from "../../common/k8s-api/kube-object.store"; +import type { KubeJsonApiDataFor, KubeObject } from "../../common/k8s-api/kube-object"; +import type { DerivedKubeApiOptions, KubeApiDependencies, KubeApiOptions } from "../../common/k8s-api/kube-api"; +import { KubeApi as InternalKubeApi } from "../../common/k8s-api/kube-api"; +import clusterFrameContextForNamespacedResourcesInjectable from "../../renderer/cluster-frame-context/for-namespaced-resources.injectable"; +import type { ClusterContext } from "../../renderer/cluster-frame-context/cluster-frame-context"; +import loggerInjectable from "../../common/logger.injectable"; +import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import maybeKubeApiInjectable from "../../common/k8s-api/maybe-kube-api.injectable"; +import { DeploymentApi as InternalDeploymentApi, IngressApi as InternalIngressApi, NodeApi, PersistentVolumeClaimApi, PodApi } from "../../common/k8s-api/endpoints"; + +export const apiManager = asLegacyGlobalForExtensionApi(apiManagerInjectable); +export const forCluster = asLegacyGlobalFunctionForExtensionApi(createKubeApiForClusterInjectable); +export const forRemoteCluster = asLegacyGlobalFunctionForExtensionApi(createKubeApiForRemoteClusterInjectable); + +const getKubeApiDeps = (): KubeApiDependencies => { + const di = getLegacyGlobalDiForExtensionApi(); + + return { + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }; +}; + +// NOTE: this is done to preserve `instanceOf` behaviour +function KubeApiCstr< + Object extends KubeObject = KubeObject, + Data extends KubeJsonApiDataFor = KubeJsonApiDataFor, +>(opts: KubeApiOptions) { + return new InternalKubeApi(getKubeApiDeps(), opts); +} + +export const KubeApi = KubeApiCstr as unknown as new< + Object extends KubeObject = KubeObject, + Data extends KubeJsonApiDataFor = KubeJsonApiDataFor, +>(opts: KubeApiOptions) => InternalKubeApi; + +export const createResourceStack = asLegacyGlobalFunctionForExtensionApi(createResourceStackInjectable); + +/** + * @deprecated Switch to using `Common.createResourceStack` instead + */ +export class ResourceStack implements ResourceApplyingStack { + private readonly impl: ResourceApplyingStack; + + constructor(cluster: KubernetesCluster, name: string) { + this.impl = createResourceStack(cluster, name); + } + + kubectlApplyFolder(folderPath: string, templateContext?: any, extraArgs?: string[] | undefined): Promise { + return this.impl.kubectlApplyFolder(folderPath, templateContext, extraArgs); + } + + kubectlDeleteFolder(folderPath: string, templateContext?: any, extraArgs?: string[] | undefined): Promise { + return this.impl.kubectlDeleteFolder(folderPath, templateContext, extraArgs); + } +} + +/** + * @deprecated This type is unused + */ +export interface IKubeApiCluster { + metadata: { + uid: string; + }; +} + +export type { CreateKubeApiForRemoteClusterConfig as IRemoteKubeApiConfig } from "../../common/k8s-api/create-kube-api-for-remote-cluster.injectable"; +export type { CreateKubeApiForLocalClusterConfig as ILocalKubeApiConfig } from "../../common/k8s-api/create-kube-api-for-cluster.injectable"; + +export { + KubeObject, + KubeStatus, + type OwnerReference, + type KubeObjectMetadata, + type NamespaceScopedMetadata, + type ClusterScopedMetadata, + type BaseKubeJsonApiObjectMetadata, + type KubeJsonApiObjectMetadata, + type KubeStatusData, +} from "../../common/k8s-api/kube-object"; + +export { + KubeJsonApi, + type KubeJsonApiData, +} from "../../common/k8s-api/kube-json-api"; + +export abstract class KubeObjectStore< + K extends KubeObject = KubeObject, + A extends InternalKubeApi = InternalKubeApi>, + D extends KubeJsonApiDataFor = KubeApiDataFrom, +> extends InternalKubeObjectStore { + /** + * @deprecated This is no longer used and shouldn't have been every really used + */ + static readonly context = { + set: (ctx: ClusterContext) => { + console.warn("Setting KubeObjectStore.context is no longer supported"); + void ctx; + }, + get: () => asLegacyGlobalForExtensionApi(clusterFrameContextForNamespacedResourcesInjectable), + }; + + get context() { + return this.dependencies.context; + } + + constructor(api: A, opts?: KubeObjectStoreOptions); + /** + * @deprecated Supply API instance through constructor + */ + constructor(); + constructor(api?: A, opts?: KubeObjectStoreOptions) { + const di = getLegacyGlobalDiForExtensionApi(); + + super( + { + context: di.inject(clusterFrameContextForNamespacedResourcesInjectable), + logger: di.inject(loggerInjectable), + }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api!, + opts, + ); + } +} + +export { + type JsonPatch, + type KubeObjectStoreLoadAllParams, + type KubeObjectStoreLoadingParams, + type KubeObjectStoreSubscribeParams, +} from "../../common/k8s-api/kube-object.store"; + +/** + * @deprecated This type is only present for backwards compatable typescript support + */ +export interface IgnoredKubeApiOptions { + /** + * @deprecated this option is overridden and should not be used + */ + objectConstructor?: any; + /** + * @deprecated this option is overridden and should not be used + */ + kind?: any; + /** + * @deprecated this option is overridden and should not be used + */ + isNamespaces?: any; + /** + * @deprecated this option is overridden and should not be used + */ + apiBase?: any; +} + +// NOTE: these *Constructor functions MUST be `function` to work with `new X()` +function PodsApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) { + return new PodApi(getKubeApiDeps(), opts ?? {}); +} + +export const PodsApi = PodsApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => PodApi; + +function NodesApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) { + return new NodeApi(getKubeApiDeps(), opts ?? {}); +} + +export const NodesApi = NodesApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => NodeApi; + +function DeploymentApiConstructor(opts?: DerivedKubeApiOptions) { + return new InternalDeploymentApi(getKubeApiDeps(), opts ?? {}); +} + +export const DeploymentApi = DeploymentApiConstructor as unknown as new (opts?: DerivedKubeApiOptions) => InternalDeploymentApi; + +function IngressApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) { + return new InternalIngressApi(getKubeApiDeps(), opts ?? {}); +} + +export const IngressApi = IngressApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => InternalIngressApi; + +function PersistentVolumeClaimsApiConstructor(opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) { + return new PersistentVolumeClaimApi(getKubeApiDeps(), opts ?? {}); +} + +export const PersistentVolumeClaimsApi = PersistentVolumeClaimsApiConstructor as unknown as new (opts?: DerivedKubeApiOptions & IgnoredKubeApiOptions) => PersistentVolumeClaimApi; + +export { + type Container as IPodContainer, + type PodContainerStatus as IPodContainerStatus, + Pod, + Node, + Deployment, + DaemonSet, + StatefulSet, + Job, + CronJob, + ConfigMap, + type SecretReference as ISecretRef, + Secret, + ReplicaSet, + ResourceQuota, + LimitRange, + HorizontalPodAutoscaler, + PodDisruptionBudget, + PriorityClass, + Service, + Endpoints as Endpoint, + Ingress, + NetworkPolicy, + PersistentVolume, + PersistentVolumeClaim, + StorageClass, + Namespace, + KubeEvent, + ServiceAccount, + Role, + RoleBinding, + ClusterRole, + ClusterRoleBinding, + CustomResourceDefinition, +} from "../../common/k8s-api/endpoints"; diff --git a/packages/core/src/extensions/common-api/proxy.ts b/packages/core/src/extensions/common-api/proxy.ts new file mode 100644 index 0000000000..bf0cd8e626 --- /dev/null +++ b/packages/core/src/extensions/common-api/proxy.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import { resolveSystemProxyInjectionToken } from "../../common/utils/resolve-system-proxy/resolve-system-proxy-injection-token"; + +/** + * Resolves URL-specific proxy information from system. See more here: https://www.electronjs.org/docs/latest/api/session#sesresolveproxyurl + * @param url - The URL for proxy information + * @returns Promise for proxy information as string + */ +export const resolveSystemProxy = asLegacyGlobalFunctionForExtensionApi(resolveSystemProxyInjectionToken); diff --git a/packages/core/src/extensions/common-api/registrations.ts b/packages/core/src/extensions/common-api/registrations.ts new file mode 100644 index 0000000000..50a9262419 --- /dev/null +++ b/packages/core/src/extensions/common-api/registrations.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export type { StatusBarRegistration } from "../../renderer/components/status-bar/status-bar-registration"; +export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../../renderer/components/kube-object-menu/kube-object-menu-registration"; +export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-registration"; +export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../../renderer/components/kube-object-details/kube-object-detail-registration"; +export type { KubeObjectStatusRegistration } from "../../renderer/components/kube-object-status-icon/kube-object-status-registration"; +export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../../renderer/routes/page-registration"; +export type { ClusterPageMenuRegistration, ClusterPageMenuComponents } from "../../renderer/components/layout/cluster-page-menu"; +export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../../common/protocol-handler/registration"; +export type { CustomCategoryViewProps, CustomCategoryViewComponents, CustomCategoryViewRegistration } from "../../renderer/components/+catalog/custom-views"; +export type { ShellEnvModifier, ShellEnvContext } from "../../main/shell-session/shell-env-modifier/shell-env-modifier-registration"; +export type { KubeObjectContextMenuItem, KubeObjectOnContextMenuOpenContext, KubeObjectOnContextMenuOpen, KubeObjectHandlers, KubeObjectHandlerRegistration } from "../../renderer/kube-object/handler"; +export type { TrayMenuRegistration } from "../../main/tray/tray-menu-registration"; diff --git a/packages/core/src/extensions/common-api/stores.ts b/packages/core/src/extensions/common-api/stores.ts new file mode 100644 index 0000000000..b369579d70 --- /dev/null +++ b/packages/core/src/extensions/common-api/stores.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export { ExtensionStore } from "../extension-store"; diff --git a/packages/core/src/extensions/common-api/types.ts b/packages/core/src/extensions/common-api/types.ts new file mode 100644 index 0000000000..febb2c4e56 --- /dev/null +++ b/packages/core/src/extensions/common-api/types.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type IpcMainInvokeEvent = Electron.IpcMainInvokeEvent; +export type IpcRendererEvent = Electron.IpcRendererEvent; +export type IpcMainEvent = Electron.IpcMainEvent; + +export * from "./registrations"; diff --git a/packages/core/src/extensions/common-api/user-preferences.ts b/packages/core/src/extensions/common-api/user-preferences.ts new file mode 100644 index 0000000000..ed925bd05d --- /dev/null +++ b/packages/core/src/extensions/common-api/user-preferences.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import userStoreInjectable from "../../common/user-store/user-store.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +export interface UserPreferenceExtensionItems { + /** + * Get the configured kubectl binaries path. + */ + getKubectlPath: () => string | undefined; +} + +const userStore = asLegacyGlobalForExtensionApi(userStoreInjectable); + +export const Preferences: UserPreferenceExtensionItems = { + getKubectlPath: () => userStore.kubectlBinariesPath, +}; diff --git a/packages/core/src/extensions/common-api/utils.ts b/packages/core/src/extensions/common-api/utils.ts new file mode 100644 index 0000000000..a6dfdee447 --- /dev/null +++ b/packages/core/src/extensions/common-api/utils.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import openLinkInBrowserInjectable from "../../common/utils/open-link-in-browser.injectable"; +import buildVersionInjectable from "../../main/vars/build-version/build-version.injectable"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import { Singleton } from "../../common/utils"; +import { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault"; +import type { IClassName } from "../../renderer/utils/cssNames"; +import { cssNames } from "../../renderer/utils/cssNames"; + +export interface UtilsExtensionItems { + Singleton: typeof Singleton; + prevDefault: (callback: (evt: E) => R) => (evt: E) => R; + stopPropagation: (evt: Event | React.SyntheticEvent) => void; + cssNames: (...classNames: IClassName[]) => string; + openExternal: (url: string) => Promise; + openBrowser: (url: string) => Promise; + getAppVersion: () => string; +} + +export const Util: UtilsExtensionItems = { + Singleton, + prevDefault, + stopPropagation, + cssNames, + openExternal: asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable), + openBrowser: asLegacyGlobalFunctionForExtensionApi(openLinkInBrowserInjectable), + getAppVersion: () => { + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(buildVersionInjectable).get(); + }, +}; diff --git a/packages/core/src/extensions/extension-api.ts b/packages/core/src/extensions/extension-api.ts new file mode 100644 index 0000000000..b5165851dc --- /dev/null +++ b/packages/core/src/extensions/extension-api.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Extensions-api types bundle (main + renderer) +// Available for lens-extensions via NPM-package "@k8slens/extensions" + +import * as Common from "./common-api"; +import * as Renderer from "./renderer-api"; +import * as Main from "./main-api"; + +export { + Common, + Renderer, + Main, +}; diff --git a/packages/core/src/extensions/extension-discovery/bundled-extension-token.ts b/packages/core/src/extensions/extension-discovery/bundled-extension-token.ts new file mode 100644 index 0000000000..1a1a40f9fa --- /dev/null +++ b/packages/core/src/extensions/extension-discovery/bundled-extension-token.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { LensExtensionConstructor, LensExtensionManifest } from "../lens-extension"; + +export interface BundledExtension { + readonly manifest: LensExtensionManifest; + main: () => LensExtensionConstructor | null; + renderer: () => LensExtensionConstructor | null; +} + +export const bundledExtensionInjectionToken = getInjectionToken({ + id: "bundled-extension-path", +}); diff --git a/packages/core/src/extensions/extension-discovery/extension-discovery.injectable.ts b/packages/core/src/extensions/extension-discovery/extension-discovery.injectable.ts new file mode 100644 index 0000000000..378f519bb7 --- /dev/null +++ b/packages/core/src/extensions/extension-discovery/extension-discovery.injectable.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { ExtensionDiscovery } from "./extension-discovery"; +import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; +import isCompatibleExtensionInjectable from "./is-compatible-extension/is-compatible-extension.injectable"; +import extensionsStoreInjectable from "../extensions-store/extensions-store.injectable"; +import extensionInstallationStateStoreInjectable from "../extension-installation-state-store/extension-installation-state-store.injectable"; +import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable"; +import extensionPackageRootDirectoryInjectable from "../extension-installer/extension-package-root-directory/extension-package-root-directory.injectable"; +import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import pathExistsInjectable from "../../common/fs/path-exists.injectable"; +import watchInjectable from "../../common/fs/watch/watch.injectable"; +import accessPathInjectable from "../../common/fs/access-path.injectable"; +import copyInjectable from "../../common/fs/copy.injectable"; +import ensureDirInjectable from "../../common/fs/ensure-dir.injectable"; +import isProductionInjectable from "../../common/vars/is-production.injectable"; +import lstatInjectable from "../../common/fs/lstat.injectable"; +import readDirectoryInjectable from "../../common/fs/read-directory.injectable"; +import fileSystemSeparatorInjectable from "../../common/path/separator.injectable"; +import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable"; +import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable"; +import getRelativePathInjectable from "../../common/path/get-relative-path.injectable"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import removePathInjectable from "../../common/fs/remove.injectable"; +import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable"; +import lensResourcesDirInjectable from "../../common/vars/lens-resources-dir.injectable"; + +const extensionDiscoveryInjectable = getInjectable({ + id: "extension-discovery", + + instantiate: (di) => new ExtensionDiscovery({ + extensionLoader: di.inject(extensionLoaderInjectable), + extensionsStore: di.inject(extensionsStoreInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), + isCompatibleExtension: di.inject(isCompatibleExtensionInjectable), + installExtension: di.inject(installExtensionInjectable), + extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable), + resourcesDirectory: di.inject(lensResourcesDirInjectable), + readJsonFile: di.inject(readJsonFileInjectable), + pathExists: di.inject(pathExistsInjectable), + watch: di.inject(watchInjectable), + logger: di.inject(loggerInjectable), + accessPath: di.inject(accessPathInjectable), + copy: di.inject(copyInjectable), + removePath: di.inject(removePathInjectable), + ensureDirectory: di.inject(ensureDirInjectable), + isProduction: di.inject(isProductionInjectable), + lstat: di.inject(lstatInjectable), + readDirectory: di.inject(readDirectoryInjectable), + fileSystemSeparator: di.inject(fileSystemSeparatorInjectable), + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + getDirnameOfPath: di.inject(getDirnameOfPathInjectable), + getRelativePath: di.inject(getRelativePathInjectable), + joinPaths: di.inject(joinPathsInjectable), + homeDirectoryPath: di.inject(homeDirectoryPathInjectable), + }), +}); + +export default extensionDiscoveryInjectable; diff --git a/packages/core/src/extensions/extension-discovery/extension-discovery.test.ts b/packages/core/src/extensions/extension-discovery/extension-discovery.test.ts new file mode 100644 index 0000000000..d71f8c5292 --- /dev/null +++ b/packages/core/src/extensions/extension-discovery/extension-discovery.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { FSWatcher } from "chokidar"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable"; +import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery"; +import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { delay } from "../../renderer/utils"; +import { observable, runInAction, when } from "mobx"; +import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; +import pathExistsInjectable from "../../common/fs/path-exists.injectable"; +import watchInjectable from "../../common/fs/watch/watch.injectable"; +import extensionApiVersionInjectable from "../../common/vars/extension-api-version.injectable"; +import removePathInjectable from "../../common/fs/remove.injectable"; +import type { JoinPaths } from "../../common/path/join-paths.injectable"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable"; +import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable"; +import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable"; +import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable"; + +describe("ExtensionDiscovery", () => { + let extensionDiscovery: ExtensionDiscovery; + let readJsonFileMock: jest.Mock; + let pathExistsMock: jest.Mock; + let watchMock: jest.Mock; + let joinPaths: JoinPaths; + let homeDirectoryPath: string; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + di.override(installExtensionInjectable, () => () => Promise.resolve()); + di.override(extensionApiVersionInjectable, () => "5.0.0"); + di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); }); + di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); }); + di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); }); + + joinPaths = di.inject(joinPathsInjectable); + homeDirectoryPath = di.inject(homeDirectoryPathInjectable); + + readJsonFileMock = jest.fn(); + di.override(readJsonFileInjectable, () => readJsonFileMock); + + pathExistsMock = jest.fn(() => Promise.resolve(true)); + di.override(pathExistsInjectable, () => pathExistsMock); + + watchMock = jest.fn(); + di.override(watchInjectable, () => watchMock); + + di.override(removePathInjectable, () => async () => {}); // allow deleting files for now + + extensionDiscovery = di.inject(extensionDiscoveryInjectable); + }); + + it("emits add for added extension", async () => { + const letTestFinish = observable.box(false); + let addHandler!: (filePath: string) => void; + + readJsonFileMock.mockImplementation((p) => { + expect(p).toBe(joinPaths(homeDirectoryPath, ".k8slens/extensions/my-extension/package.json")); + + return { + name: "my-extension", + version: "1.0.0", + engines: { + lens: "5.0.0", + }, + }; + }); + + const mockWatchInstance = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }), + } as unknown as FSWatcher; + + watchMock.mockImplementationOnce(() => mockWatchInstance); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.watchExtensions(); + + extensionDiscovery.events.on("add", extension => { + expect(extension).toEqual({ + absolutePath: expect.any(String), + id: "/some-directory-for-user-data/node_modules/my-extension/package.json", + isBundled: false, + isEnabled: false, + isCompatible: true, + manifest: { + name: "my-extension", + version: "1.0.0", + engines: { + lens: "5.0.0", + }, + }, + manifestPath: "/some-directory-for-user-data/node_modules/my-extension/package.json", + }); + runInAction(() => letTestFinish.set(true)); + }); + + addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + await when(() => letTestFinish.get()); + }); + + it("doesn't emit add for added file under extension", async () => { + let addHandler!: (filePath: string) => void; + + const mockWatchInstance = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }), + } as unknown as FSWatcher; + + watchMock.mockImplementationOnce(() => mockWatchInstance); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.watchExtensions(); + + const onAdd = jest.fn(); + + extensionDiscovery.events.on("add", onAdd); + + addHandler(joinPaths(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); + + await delay(10); + + expect(onAdd).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/extensions/extension-discovery/extension-discovery.ts b/packages/core/src/extensions/extension-discovery/extension-discovery.ts new file mode 100644 index 0000000000..c9646a1c6c --- /dev/null +++ b/packages/core/src/extensions/extension-discovery/extension-discovery.ts @@ -0,0 +1,443 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { ipcRenderer } from "electron"; +import { EventEmitter } from "events"; +import { makeObservable, observable, reaction, when } from "mobx"; +import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc"; +import { isErrnoException, toJS } from "../../common/utils"; +import type { ExtensionsStore } from "../extensions-store/extensions-store"; +import type { ExtensionLoader } from "../extension-loader"; +import type { LensExtensionId, LensExtensionManifest } from "../lens-extension"; +import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; +import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; +import { requestInitialExtensionDiscovery } from "../../renderer/ipc"; +import type { ReadJson } from "../../common/fs/read-json-file.injectable"; +import type { Logger } from "../../common/logger"; +import type { PathExists } from "../../common/fs/path-exists.injectable"; +import type { Watch } from "../../common/fs/watch/watch.injectable"; +import type { Stats } from "fs"; +import type { LStat } from "../../common/fs/lstat.injectable"; +import type { ReadDirectory } from "../../common/fs/read-directory.injectable"; +import type { EnsureDirectory } from "../../common/fs/ensure-dir.injectable"; +import type { AccessPath } from "../../common/fs/access-path.injectable"; +import type { Copy } from "../../common/fs/copy.injectable"; +import type { JoinPaths } from "../../common/path/join-paths.injectable"; +import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable"; +import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"; +import type { GetRelativePath } from "../../common/path/get-relative-path.injectable"; +import type { RemovePath } from "../../common/fs/remove.injectable"; +import type TypedEventEmitter from "typed-emitter"; + +interface Dependencies { + readonly extensionLoader: ExtensionLoader; + readonly extensionsStore: ExtensionsStore; + readonly extensionInstallationStateStore: ExtensionInstallationStateStore; + readonly extensionPackageRootDirectory: string; + readonly resourcesDirectory: string; + readonly logger: Logger; + readonly isProduction: boolean; + readonly fileSystemSeparator: string; + readonly homeDirectoryPath: string; + isCompatibleExtension: (manifest: LensExtensionManifest) => boolean; + installExtension: (name: string) => Promise; + readJsonFile: ReadJson; + pathExists: PathExists; + removePath: RemovePath; + lstat: LStat; + watch: Watch; + readDirectory: ReadDirectory; + ensureDirectory: EnsureDirectory; + accessPath: AccessPath; + copy: Copy; + joinPaths: JoinPaths; + getBasenameOfPath: GetBasenameOfPath; + getDirnameOfPath: GetDirnameOfPath; + getRelativePath: GetRelativePath; +} + +export interface InstalledExtension { + id: LensExtensionId; + + readonly manifest: LensExtensionManifest; + + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; + + // Absolute to the symlinked package.json file + readonly manifestPath: string; + readonly isBundled: boolean; // defined in project root's package.json + readonly isCompatible: boolean; + isEnabled: boolean; +} + +const logModule = "[EXTENSION-DISCOVERY]"; + +export const manifestFilename = "package.json"; + +interface ExtensionDiscoveryChannelMessage { + isLoaded: boolean; +} + +/** + * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) + * @param lstat the stats to compare + */ +const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); + +interface LoadFromFolderOptions { + isBundled?: boolean; +} + +interface ExtensionDiscoveryEvents { + add: (ext: InstalledExtension) => void; + remove: (extId: LensExtensionId) => void; +} + +/** + * Discovers installed bundled and local extensions from the filesystem. + * Also watches for added and removed local extensions by watching the directory. + * Uses ExtensionInstaller to install dependencies for all of the extensions. + * This is also done when a new extension is copied to the local extensions directory. + * .init() must be called to start the directory watching. + * The class emits events for added and removed extensions: + * - "add": When extension is added. The event is of type InstalledExtension + * - "remove": When extension is removed. The event is of type LensExtensionId + */ +export class ExtensionDiscovery { + protected bundledFolderPath!: string; + + private loadStarted = false; + private extensions: Map = new Map(); + + // True if extensions have been loaded from the disk after app startup + @observable isLoaded = false; + + get whenLoaded() { + return when(() => this.isLoaded); + } + + public readonly events: TypedEventEmitter = new EventEmitter(); + + constructor(protected readonly dependencies: Dependencies) { + makeObservable(this); + } + + get localFolderPath(): string { + return this.dependencies.joinPaths(this.dependencies.homeDirectoryPath, ".k8slens", "extensions"); + } + + get packageJsonPath(): string { + return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, manifestFilename); + } + + get nodeModulesPath(): string { + return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "node_modules"); + } + + /** + * Initializes the class and setups the file watcher for added/removed local extensions. + */ + async init(): Promise { + if (ipcRenderer) { + await this.initRenderer(); + } else { + await this.initMain(); + } + } + + async initRenderer(): Promise { + const onMessage = ({ isLoaded }: ExtensionDiscoveryChannelMessage) => { + this.isLoaded = isLoaded; + }; + + requestInitialExtensionDiscovery().then(onMessage); + ipcRendererOn(extensionDiscoveryStateChannel, (_event, message: ExtensionDiscoveryChannelMessage) => { + onMessage(message); + }); + } + + async initMain(): Promise { + ipcMainHandle(extensionDiscoveryStateChannel, () => this.toJSON()); + + reaction(() => this.toJSON(), () => { + this.broadcast(); + }); + } + + /** + * Watches for added/removed local extensions. + * Dependencies are installed automatically after an extension folder is copied. + */ + async watchExtensions(): Promise { + this.dependencies.logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`); + + // Wait until .load() has been called and has been resolved + await this.whenLoaded; + + this.dependencies.watch(this.localFolderPath, { + // For adding and removing symlinks to work, the depth has to be 1. + depth: 1, + ignoreInitial: true, + // Try to wait until the file has been completely copied. + // The OS might emit an event for added file even it's not completely written to the file-system. + awaitWriteFinish: { + // Wait 300ms until the file size doesn't change to consider the file written. + // For a small file like package.json this should be plenty of time. + stabilityThreshold: 300, + }, + }) + // Extension add is detected by watching "/package.json" add + .on("add", this.handleWatchFileAdd) + // Extension remove is detected by watching "" unlink + .on("unlinkDir", this.handleWatchUnlinkEvent) + // Extension remove is detected by watching "" unlink + .on("unlink", this.handleWatchUnlinkEvent); + } + + handleWatchFileAdd = async (manifestPath: string): Promise => { + // e.g. "foo/package.json" + const relativePath = this.dependencies.getRelativePath(this.localFolderPath, manifestPath); + + // Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies + // that the added file is in a folder under local folder path. + // This safeguards against a file watch being triggered under a sub-directory which is not an extension. + const isUnderLocalFolderPath = relativePath.split(this.dependencies.fileSystemSeparator).length === 2; + + if (this.dependencies.getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) { + try { + this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath); + const absPath = this.dependencies.getDirnameOfPath(manifestPath); + + // this.loadExtensionFromPath updates this.packagesJson + const extension = await this.loadExtensionFromFolder(absPath); + + if (extension) { + // Remove a broken symlink left by a previous installation if it exists. + await this.dependencies.removePath(extension.manifestPath); + + // Install dependencies for the new extension + await this.dependencies.installExtension(extension.absolutePath); + + this.extensions.set(extension.id, extension); + this.dependencies.logger.info(`${logModule} Added extension ${extension.manifest.name}`); + this.events.emit("add", extension); + } + } catch (error) { + this.dependencies.logger.error(`${logModule}: failed to add extension: ${error}`, { error }); + } finally { + this.dependencies.extensionInstallationStateStore.clearInstallingFromMain(manifestPath); + } + } + }; + + /** + * Handle any unlink event, filtering out non-package.json links so the delete code + * only happens once per extension. + * @param filePath The absolute path to either a folder or file in the extensions folder + */ + handleWatchUnlinkEvent = async (filePath: string): Promise => { + // Check that the removed path is directly under this.localFolderPath + // Note that the watcher can create unlink events for subdirectories of the extension + const extensionFolderName = this.dependencies.getBasenameOfPath(filePath); + const expectedPath = this.dependencies.getRelativePath(this.localFolderPath, filePath); + + if (expectedPath !== extensionFolderName) { + return; + } + + for (const extension of this.extensions.values()) { + if (extension.absolutePath !== filePath) { + continue; + } + + const extensionName = extension.manifest.name; + + // If the extension is deleted manually while the application is running, also remove the symlink + await this.removeSymlinkByPackageName(extensionName); + + // The path to the manifest file is the lens extension id + // Note: that we need to use the symlinked path + const lensExtensionId = extension.manifestPath; + + this.extensions.delete(extension.id); + this.dependencies.logger.info(`${logModule} removed extension ${extensionName}`); + this.events.emit("remove", lensExtensionId); + + return; + } + + this.dependencies.logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`); + }; + + /** + * Remove the symlink under node_modules if exists. + * If we don't remove the symlink, the uninstall would leave a non-working symlink, + * which wouldn't be fixed if the extension was reinstalled, causing the extension not to work. + * @param name e.g. "@mirantis/lens-extension-cc" + */ + removeSymlinkByPackageName(name: string): Promise { + return this.dependencies.removePath(this.getInstalledPath(name)); + } + + /** + * Uninstalls extension. + * The application will detect the folder unlink and remove the extension from the UI automatically. + * @param extensionId The ID of the extension to uninstall. + */ + async uninstallExtension(extensionId: LensExtensionId): Promise { + const extension = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId); + + if (!extension) { + return void this.dependencies.logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId }); + } + + const { manifest, absolutePath } = extension; + + this.dependencies.logger.info(`${logModule} Uninstalling ${manifest.name}`); + + await this.removeSymlinkByPackageName(manifest.name); + + // fs.remove does nothing if the path doesn't exist anymore + await this.dependencies.removePath(absolutePath); + } + + async load(): Promise> { + if (this.loadStarted) { + // The class is simplified by only supporting .load() to be called once + throw new Error("ExtensionDiscovery.load() can be only be called once"); + } + + this.loadStarted = true; + + this.dependencies.logger.info( + `${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`, + ); + + await this.dependencies.removePath(this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "package-lock.json")); + await this.dependencies.ensureDirectory(this.nodeModulesPath); + await this.dependencies.ensureDirectory(this.localFolderPath); + + const extensions = await this.ensureExtensions(); + + this.isLoaded = true; + + return extensions; + } + + /** + * Returns the symlinked path to the extension folder, + * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension" + */ + protected getInstalledPath(name: string): string { + return this.dependencies.joinPaths(this.nodeModulesPath, name); + } + + /** + * Returns the symlinked path to the package.json, + * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension/package.json" + */ + protected getInstalledManifestPath(name: string): string { + return this.dependencies.joinPaths(this.getInstalledPath(name), manifestFilename); + } + + /** + * Returns InstalledExtension from path to package.json file. + * Also updates this.packagesJson. + */ + protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise { + try { + const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest; + const id = isBundled ? manifestPath : this.getInstalledManifestPath(manifest.name); + const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled }); + const extensionDir = this.dependencies.getDirnameOfPath(manifestPath); + const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`); + const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage) + ? npmPackage + : extensionDir; + const isCompatible = isBundled || this.dependencies.isCompatibleExtension(manifest); + + return { + id, + absolutePath, + manifestPath: id, + manifest, + isBundled, + isEnabled, + isCompatible, + }; + } catch (error) { + if (isErrnoException(error) && error.code === "ENOTDIR") { + // ignore this error, probably from .DS_Store file + this.dependencies.logger.debug(`${logModule}: failed to load extension manifest through a not-dir-like at ${manifestPath}`); + } else { + this.dependencies.logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`); + } + + return null; + } + } + + async ensureExtensions(): Promise> { + const userExtensions = await this.loadFromFolder(this.localFolderPath); + + return this.extensions = new Map(userExtensions.map(extension => [extension.id, extension])); + } + + async loadFromFolder(folderPath: string): Promise { + const extensions: InstalledExtension[] = []; + const paths = await this.dependencies.readDirectory(folderPath); + + for (const fileName of paths) { + const absPath = this.dependencies.joinPaths(folderPath, fileName); + + try { + const lstat = await this.dependencies.lstat(absPath); + + // skip non-directories + if (!isDirectoryLike(lstat)) { + continue; + } + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + continue; + } + + throw error; + } + + const extension = await this.loadExtensionFromFolder(absPath); + + if (extension) { + extensions.push(extension); + } + } + + this.dependencies.logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions }); + + return extensions; + } + + /** + * Loads extension from absolute path, updates this.packagesJson to include it and returns the extension. + * @param folderPath Folder path to extension + */ + async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise { + const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename); + + return this.getByManifest(manifestPath, { isBundled }); + } + + toJSON(): ExtensionDiscoveryChannelMessage { + return toJS({ + isLoaded: this.isLoaded, + }); + } + + broadcast(): void { + broadcastMessage(extensionDiscoveryStateChannel, this.toJSON()); + } +} diff --git a/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts b/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts new file mode 100644 index 0000000000..de2fd4390f --- /dev/null +++ b/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import extensionApiVersionInjectable from "../../../common/vars/extension-api-version.injectable"; +import { isCompatibleExtension } from "./is-compatible-extension"; + +const isCompatibleExtensionInjectable = getInjectable({ + id: "is-compatible-extension", + instantiate: (di) => isCompatibleExtension({ + extensionApiVersion: di.inject(extensionApiVersionInjectable), + }), +}); + +export default isCompatibleExtensionInjectable; diff --git a/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts b/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts new file mode 100644 index 0000000000..74cbb4fd0c --- /dev/null +++ b/packages/core/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import semver from "semver"; +import type { LensExtensionManifest } from "../../lens-extension"; + +interface Dependencies { + extensionApiVersion: string; +} + +export const isCompatibleExtension = ({ extensionApiVersion }: Dependencies): ((manifest: LensExtensionManifest) => boolean) => { + return (manifest: LensExtensionManifest): boolean => { + const manifestLensEngine = manifest.engines.lens; + const validVersion = manifestLensEngine.match(/^[\^0-9]\d*\.\d+\b/); // must start from ^ or number + + if (!validVersion) { + const errorInfo = [ + `Invalid format for "manifest.engines.lens"="${manifestLensEngine}"`, + `Range versions can only be specified starting with '^'.`, + `Otherwise it's recommended to use plain %MAJOR.%MINOR to match with supported Lens version.`, + ].join("\n"); + + throw new Error(errorInfo); + } + + const { major: extMajor, minor: extMinor } = semver.coerce(manifestLensEngine, { + loose: true, + }) as semver.SemVer; + const supportedVersionsByExtension = semver.validRange(`^${extMajor}.${extMinor}`) as string; + + return semver.satisfies(extensionApiVersion, supportedVersionsByExtension, { + loose: true, + includePrerelease: false, + }); + }; +}; diff --git a/packages/core/src/extensions/extension-installation-state-store/extension-installation-state-store.injectable.ts b/packages/core/src/extensions/extension-installation-state-store/extension-installation-state-store.injectable.ts new file mode 100644 index 0000000000..e7ad2285bc --- /dev/null +++ b/packages/core/src/extensions/extension-installation-state-store/extension-installation-state-store.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import { ExtensionInstallationStateStore } from "./extension-installation-state-store"; + +const extensionInstallationStateStoreInjectable = getInjectable({ + id: "extension-installation-state-store", + instantiate: (di) => new ExtensionInstallationStateStore({ + logger: di.inject(loggerInjectable), + }), +}); + +export default extensionInstallationStateStoreInjectable; diff --git a/packages/core/src/extensions/extension-installation-state-store/extension-installation-state-store.ts b/packages/core/src/extensions/extension-installation-state-store/extension-installation-state-store.ts new file mode 100644 index 0000000000..093c80934b --- /dev/null +++ b/packages/core/src/extensions/extension-installation-state-store/extension-installation-state-store.ts @@ -0,0 +1,247 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, computed, observable } from "mobx"; +import { disposer } from "../../renderer/utils"; +import type { ExtendableDisposer } from "../../renderer/utils"; +import * as uuid from "uuid"; +import { broadcastMessage } from "../../common/ipc"; +import { ipcRenderer } from "electron"; +import type { Logger } from "../../common/logger"; + +export enum ExtensionInstallationState { + INSTALLING = "installing", + UNINSTALLING = "uninstalling", + IDLE = "idle", +} + +interface Dependencies { + readonly logger: Logger; +} + +const Prefix = "[ExtensionInstallationStore]"; + +const installingFromMainChannel = "extension-installation-state-store:install"; +const clearInstallingFromMainChannel = "extension-installation-state-store:clear-install"; + +export class ExtensionInstallationStateStore { + private readonly preInstallIds = observable.set(); + private readonly uninstallingExtensions = observable.set(); + private readonly installingExtensions = observable.set(); + + constructor(private readonly dependencies: Dependencies) {} + + bindIpcListeners = () => { + ipcRenderer + .on(installingFromMainChannel, (event, extId) => { + this.setInstalling(extId); + }) + + .on(clearInstallingFromMainChannel, (event, extId) => { + this.clearInstalling(extId); + }); + }; + + /** + * Strictly transitions an extension from not installing to installing + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action setInstalling = (extId: string): void => { + this.dependencies.logger.debug(`${Prefix}: trying to set ${extId} as installing`); + + const curState = this.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error( + `${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`, + ); + } + + this.installingExtensions.add(extId); + }; + + /** + * Broadcasts that an extension is being installed by the main process + * @param extId the ID of the extension + */ + setInstallingFromMain = (extId: string): void => { + broadcastMessage(installingFromMainChannel, extId); + }; + + /** + * Broadcasts that an extension is no longer being installed by the main process + * @param extId the ID of the extension + */ + clearInstallingFromMain = (extId: string): void => { + broadcastMessage(clearInstallingFromMainChannel, extId); + }; + + /** + * Marks the start of a pre-install phase of an extension installation. The + * part of the installation before the tarball has been unpacked and the ID + * determined. + * @returns a disposer which should be called to mark the end of the install phase + */ + @action startPreInstall = (): ExtendableDisposer => { + const preInstallStepId = uuid.v4(); + + this.dependencies.logger.debug( + `${Prefix}: starting a new preinstall phase: ${preInstallStepId}`, + ); + this.preInstallIds.add(preInstallStepId); + + return disposer(() => { + this.preInstallIds.delete(preInstallStepId); + this.dependencies.logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`); + }); + }; + + /** + * Strictly transitions an extension from not uninstalling to uninstalling + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action setUninstalling = (extId: string): void => { + this.dependencies.logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`); + + const curState = this.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error( + `${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`, + ); + } + + this.uninstallingExtensions.add(extId); + }; + + /** + * Strictly clears the INSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not INSTALLING + */ + @action clearInstalling = (extId: string): void => { + this.dependencies.logger.debug(`${Prefix}: trying to clear ${extId} as installing`); + + const curState = this.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.INSTALLING: + return void this.installingExtensions.delete(extId); + default: + throw new Error( + `${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`, + ); + } + }; + + /** + * Strictly clears the UNINSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not UNINSTALLING + */ + @action clearUninstalling = (extId: string): void => { + this.dependencies.logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`); + + const curState = this.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.UNINSTALLING: + return void this.uninstallingExtensions.delete(extId); + default: + throw new Error( + `${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`, + ); + } + }; + + /** + * Returns the current state of the extension. IDLE is default value. + * @param extId The ID of the extension + */ + getInstallationState = (extId: string): ExtensionInstallationState => { + if (this.installingExtensions.has(extId)) { + return ExtensionInstallationState.INSTALLING; + } + + if (this.uninstallingExtensions.has(extId)) { + return ExtensionInstallationState.UNINSTALLING; + } + + return ExtensionInstallationState.IDLE; + }; + + /** + * Returns true if the extension is currently INSTALLING + * @param extId The ID of the extension + */ + isExtensionInstalling = (extId: string): boolean => + this.getInstallationState(extId) === ExtensionInstallationState.INSTALLING; + + /** + * Returns true if the extension is currently UNINSTALLING + * @param extId The ID of the extension + */ + isExtensionUninstalling = (extId: string): boolean => + this.getInstallationState(extId) === + ExtensionInstallationState.UNINSTALLING; + + /** + * Returns true if the extension is currently IDLE + * @param extId The ID of the extension + */ + isExtensionIdle = (extId: string): boolean => + this.getInstallationState(extId) === ExtensionInstallationState.IDLE; + + /** + * The current number of extensions installing + */ + @computed get installing(): number { + return this.installingExtensions.size; + } + + /** + * The current number of extensions uninstalling + */ + get uninstalling(): number { + return this.uninstallingExtensions.size; + } + + /** + * If there is at least one extension currently installing + */ + get anyInstalling(): boolean { + return this.installing > 0; + } + + /** + * If there is at least one extension currently uninstalling + */ + get anyUninstalling(): boolean { + return this.uninstalling > 0; + } + + /** + * The current number of extensions preinstalling + */ + get preinstalling(): number { + return this.preInstallIds.size; + } + + /** + * If there is at least one extension currently downloading + */ + get anyPreinstalling(): boolean { + return this.preinstalling > 0; + } + + /** + * If there is at least one installing or preinstalling step taking place + */ + get anyPreInstallingOrInstalling(): boolean { + return this.anyInstalling || this.anyPreinstalling; + } +} diff --git a/packages/core/src/extensions/extension-installer/extension-installer.injectable.ts b/packages/core/src/extensions/extension-installer/extension-installer.injectable.ts new file mode 100644 index 0000000000..92b4436701 --- /dev/null +++ b/packages/core/src/extensions/extension-installer/extension-installer.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import pathToNpmCliInjectable from "../../common/app-paths/path-to-npm-cli.injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import { ExtensionInstaller } from "./extension-installer"; +import extensionPackageRootDirectoryInjectable from "./extension-package-root-directory/extension-package-root-directory.injectable"; + +const extensionInstallerInjectable = getInjectable({ + id: "extension-installer", + + instantiate: (di) => new ExtensionInstaller({ + extensionPackageRootDirectory: di.inject(extensionPackageRootDirectoryInjectable), + logger: di.inject(loggerInjectable), + pathToNpmCli: di.inject(pathToNpmCliInjectable), + }), +}); + +export default extensionInstallerInjectable; diff --git a/packages/core/src/extensions/extension-installer/extension-installer.ts b/packages/core/src/extensions/extension-installer/extension-installer.ts new file mode 100644 index 0000000000..223477d0c4 --- /dev/null +++ b/packages/core/src/extensions/extension-installer/extension-installer.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import AwaitLock from "await-lock"; +import child_process from "child_process"; +import type { Logger } from "../../common/logger"; + +const logModule = "[EXTENSION-INSTALLER]"; + +interface Dependencies { + readonly extensionPackageRootDirectory: string; + readonly logger: Logger; + readonly pathToNpmCli: string; +} + +const baseNpmInstallArgs = [ + "install", + "--audit=false", + "--fund=false", + // NOTE: we do not omit the `optional` dependencies because that is how we specify the non-bundled extensions + "--omit=dev", + "--omit=peer", + "--prefer-offline", +]; + +/** + * Installs dependencies for extensions + */ +export class ExtensionInstaller { + private readonly installLock = new AwaitLock(); + + constructor(private readonly dependencies: Dependencies) {} + + /** + * Install single package using npm + */ + installPackage = async (name: string): Promise => { + // Mutual exclusion to install packages in sequence + await this.installLock.acquireAsync(); + + try { + this.dependencies.logger.info(`${logModule} installing package from ${name} to ${this.dependencies.extensionPackageRootDirectory}`); + await this.npm(...baseNpmInstallArgs, name); + this.dependencies.logger.info(`${logModule} package ${name} installed to ${this.dependencies.extensionPackageRootDirectory}`); + } finally { + this.installLock.release(); + } + }; + + private npm(...args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = child_process.fork(this.dependencies.pathToNpmCli, args, { + cwd: this.dependencies.extensionPackageRootDirectory, + silent: true, + env: {}, + }); + let stderr = ""; + + child.stderr?.on("data", data => { + stderr += String(data); + }); + + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(stderr)); + } else { + resolve(); + } + }); + + child.on("error", error => { + reject(error); + }); + }); + } +} diff --git a/packages/core/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts b/packages/core/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts new file mode 100644 index 0000000000..72bd0ad8c2 --- /dev/null +++ b/packages/core/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; + +const extensionPackageRootDirectoryInjectable = getInjectable({ + id: "extension-package-root-directory", + + instantiate: (di) => di.inject(directoryForUserDataInjectable), +}); + +export default extensionPackageRootDirectoryInjectable; diff --git a/packages/core/src/extensions/extension-installer/install-extension/install-extension.injectable.ts b/packages/core/src/extensions/extension-installer/install-extension/install-extension.injectable.ts new file mode 100644 index 0000000000..940c5987a5 --- /dev/null +++ b/packages/core/src/extensions/extension-installer/install-extension/install-extension.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import extensionInstallerInjectable from "../extension-installer.injectable"; + +const installExtensionInjectable = getInjectable({ + id: "install-extension", + instantiate: (di) => di.inject(extensionInstallerInjectable).installPackage, +}); + +export default installExtensionInjectable; diff --git a/packages/core/src/extensions/extension-loader/create-extension-instance.token.ts b/packages/core/src/extensions/extension-loader/create-extension-instance.token.ts new file mode 100644 index 0000000000..d7680b018b --- /dev/null +++ b/packages/core/src/extensions/extension-loader/create-extension-instance.token.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { InstalledExtension } from "../extension-discovery/extension-discovery"; +import type { LensExtension, LensExtensionConstructor } from "../lens-extension"; + +export type CreateExtensionInstance = (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => LensExtension; + +export const createExtensionInstanceInjectionToken = getInjectionToken({ + id: "create-extension-instance-token", +}); diff --git a/packages/core/src/extensions/extension-loader/entry-point-name.ts b/packages/core/src/extensions/extension-loader/entry-point-name.ts new file mode 100644 index 0000000000..390da4a34a --- /dev/null +++ b/packages/core/src/extensions/extension-loader/entry-point-name.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const extensionEntryPointNameInjectionToken = getInjectionToken<"main" | "renderer">({ + id: "extension-entry-point-name-token", +}); diff --git a/packages/core/src/extensions/extension-loader/extension-instances.injectable.ts b/packages/core/src/extensions/extension-loader/extension-instances.injectable.ts new file mode 100644 index 0000000000..08e7ad4cc5 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/extension-instances.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { LensExtensionId, LensExtension } from "../lens-extension"; + +const extensionInstancesInjectable = getInjectable({ + id: "extension-instances", + instantiate: () => observable.map(), +}); + +export default extensionInstancesInjectable; diff --git a/packages/core/src/extensions/extension-loader/extension-is-enabled-for-cluster.injectable.ts b/packages/core/src/extensions/extension-loader/extension-is-enabled-for-cluster.injectable.ts new file mode 100644 index 0000000000..23e1c87561 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/extension-is-enabled-for-cluster.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { KubernetesCluster } from "../../common/catalog-entities"; +import type { LensRendererExtension } from "../lens-renderer-extension"; + +interface ExtensionIsEnabledForCluster { + extension: LensRendererExtension; + cluster: KubernetesCluster; +} + +const extensionIsEnabledForClusterInjectable = getInjectable({ + id: "extension-is-enabled-for-cluster", + + instantiate: async ( + di, + { extension, cluster }: ExtensionIsEnabledForCluster, + ) => (await extension.isEnabledForCluster(cluster)) as boolean, + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: ( + di, + { extension, cluster }: ExtensionIsEnabledForCluster, + ) => `${extension.sanitizedExtensionId}-${cluster.getId()}`, + }), +}); + +export default extensionIsEnabledForClusterInjectable; diff --git a/packages/core/src/extensions/extension-loader/extension-loader.injectable.ts b/packages/core/src/extensions/extension-loader/extension-loader.injectable.ts new file mode 100644 index 0000000000..67f8434043 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/extension-loader.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { ExtensionLoader } from "./extension-loader"; +import updateExtensionsStateInjectable from "./update-extensions-state/update-extensions-state.injectable"; +import { createExtensionInstanceInjectionToken } from "./create-extension-instance.token"; +import extensionInstancesInjectable from "./extension-instances.injectable"; +import type { LensExtension } from "../lens-extension"; +import extensionInjectable from "./extension/extension.injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable"; +import { bundledExtensionInjectionToken } from "../extension-discovery/bundled-extension-token"; +import { extensionEntryPointNameInjectionToken } from "./entry-point-name"; + +const extensionLoaderInjectable = getInjectable({ + id: "extension-loader", + + instantiate: (di) => new ExtensionLoader({ + updateExtensionsState: di.inject(updateExtensionsStateInjectable), + createExtensionInstance: di.inject(createExtensionInstanceInjectionToken), + extensionInstances: di.inject(extensionInstancesInjectable), + getExtension: (instance: LensExtension) => di.inject(extensionInjectable, instance), + bundledExtensions: di.injectMany(bundledExtensionInjectionToken), + extensionEntryPointName: di.inject(extensionEntryPointNameInjectionToken), + logger: di.inject(loggerInjectable), + joinPaths: di.inject(joinPathsInjectable), + getDirnameOfPath: di.inject(getDirnameOfPathInjectable), + }), +}); + +export default extensionLoaderInjectable; diff --git a/packages/core/src/extensions/extension-loader/extension-loader.ts b/packages/core/src/extensions/extension-loader/extension-loader.ts new file mode 100644 index 0000000000..5dce874e54 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/extension-loader.ts @@ -0,0 +1,428 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { ipcMain, ipcRenderer } from "electron"; +import { isEqual } from "lodash"; +import type { ObservableMap } from "mobx"; +import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; +import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc"; +import { isDefined, toJS } from "../../common/utils"; +import type { InstalledExtension } from "../extension-discovery/extension-discovery"; +import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; +import type { LensExtensionState } from "../extensions-store/extensions-store"; +import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling"; +import { requestExtensionLoaderInitialState } from "../../renderer/ipc"; +import assert from "assert"; +import { EventEmitter } from "../../common/event-emitter"; +import type { CreateExtensionInstance } from "./create-extension-instance.token"; +import type { Extension } from "./extension/extension.injectable"; +import type { Logger } from "../../common/logger"; +import type { JoinPaths } from "../../common/path/join-paths.injectable"; +import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"; +import type { BundledExtension } from "../extension-discovery/bundled-extension-token"; + +const logModule = "[EXTENSIONS-LOADER]"; + +interface Dependencies { + readonly extensionInstances: ObservableMap; + readonly bundledExtensions: BundledExtension[]; + readonly logger: Logger; + readonly extensionEntryPointName: "main" | "renderer"; + updateExtensionsState: (extensionsState: Record) => void; + createExtensionInstance: CreateExtensionInstance; + getExtension: (instance: LensExtension) => Extension; + joinPaths: JoinPaths; + getDirnameOfPath: GetDirnameOfPath; +} + +interface ExtensionBeingActivated { + instance: LensExtension; + installedExtension: InstalledExtension; + activated: Promise; +} + +export interface ExtensionLoading { + isBundled: boolean; + loaded: Promise; +} + +/** + * Loads installed extensions to the Lens application + */ +export class ExtensionLoader { + protected readonly extensions = observable.map(); + + /** + * This is the set of extensions that don't come with either + * - Main.LensExtension when running in the main process + * - Renderer.LensExtension when running in the renderer process + */ + protected readonly nonInstancesByName = observable.set(); + + /** + * This is updated by the `observe` in the constructor. DO NOT write directly to it + */ + protected readonly instancesByName = observable.map(); + + private readonly onRemoveExtensionId = new EventEmitter<[string]>(); + + @observable isLoaded = false; + + get whenLoaded() { + return when(() => this.isLoaded); + } + + constructor(protected readonly dependencies: Dependencies) { + makeObservable(this); + + observe(this.dependencies.extensionInstances, change => { + switch (change.type) { + case "add": + if (this.instancesByName.has(change.newValue.name)) { + throw new TypeError("Extension names must be unique"); + } + + this.instancesByName.set(change.newValue.name, change.newValue); + break; + case "delete": + this.instancesByName.delete(change.oldValue.name); + break; + case "update": + throw new Error("Extension instances shouldn't be updated"); + } + }); + } + + @computed get userExtensions(): Map { + const extensions = this.toJSON(); + + extensions.forEach((ext, extId) => { + if (ext.isBundled) { + extensions.delete(extId); + } + }); + + return extensions; + } + + /** + * Get the extension instance by its manifest name + * @param name The name of the extension + * @returns one of the following: + * - the instance of `Main.LensExtension` on the main process if created + * - the instance of `Renderer.LensExtension` on the renderer process if created + * - `null` if no class definition is provided for the current process + * - `undefined` if the name is not known about + */ + getInstanceByName(name: string): LensExtension | null | undefined { + if (this.nonInstancesByName.has(name)) { + return null; + } + + return this.instancesByName.get(name); + } + + // Transform userExtensions to a state object for storing into ExtensionsStore + @computed get storeState() { + return Object.fromEntries( + Array.from(this.userExtensions) + .map(([extId, extension]) => [extId, { + enabled: extension.isEnabled, + name: extension.manifest.name, + }]), + ); + } + + @action + async init() { + if (ipcMain) { + await this.initMain(); + } else { + await this.initRenderer(); + } + + await Promise.all([this.whenLoaded]); + + // broadcasting extensions between main/renderer processes + reaction(() => this.toJSON(), () => this.broadcastExtensions(), { + fireImmediately: true, + }); + + reaction( + () => this.storeState, + + (state) => { + this.dependencies.updateExtensionsState(state); + }, + ); + } + + initExtensions(extensions: Map) { + this.extensions.replace(extensions); + } + + addExtension(extension: InstalledExtension) { + this.extensions.set(extension.id, extension); + } + + @action + removeInstance(lensExtensionId: LensExtensionId) { + this.dependencies.logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); + const instance = this.dependencies.extensionInstances.get(lensExtensionId); + + if (!instance) { + return; + } + + try { + instance.disable(); + + const extension = this.dependencies.getExtension(instance); + + extension.deregister(); + + this.onRemoveExtensionId.emit(instance.id); + this.dependencies.extensionInstances.delete(lensExtensionId); + this.nonInstancesByName.delete(instance.name); + } catch (error) { + this.dependencies.logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); + } + } + + removeExtension(lensExtensionId: LensExtensionId) { + this.removeInstance(lensExtensionId); + + if (!this.extensions.delete(lensExtensionId)) { + throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`); + } + } + + setIsEnabled(lensExtensionId: LensExtensionId, isEnabled: boolean) { + const extension = this.extensions.get(lensExtensionId); + + assert(extension, `Must register extension ${lensExtensionId} with before enabling it`); + + extension.isEnabled = isEnabled; + } + + protected async initMain() { + this.isLoaded = true; + await this.autoInitExtensions(); + + ipcMainHandle(extensionLoaderFromMainChannel, () => { + return Array.from(this.toJSON()); + }); + + ipcMainOn(extensionLoaderFromRendererChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { + this.syncExtensions(extensions); + }); + } + + protected async initRenderer() { + const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => { + this.isLoaded = true; + this.syncExtensions(extensions); + + const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId); + + // Remove deleted extensions in renderer side only + this.extensions.forEach((_, lensExtensionId) => { + if (!receivedExtensionIds.includes(lensExtensionId)) { + this.removeExtension(lensExtensionId); + } + }); + }; + + requestExtensionLoaderInitialState().then(extensionListHandler); + ipcRendererOn(extensionLoaderFromMainChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { + extensionListHandler(extensions); + }); + } + + broadcastExtensions() { + const channel = ipcRenderer + ? extensionLoaderFromRendererChannel + : extensionLoaderFromMainChannel; + + broadcastMessage(channel, Array.from(this.extensions)); + } + + syncExtensions(extensions: [LensExtensionId, InstalledExtension][]) { + extensions.forEach(([lensExtensionId, extension]) => { + if (!isEqual(this.extensions.get(lensExtensionId), extension)) { + this.extensions.set(lensExtensionId, extension); + } + }); + } + + protected async loadBundledExtensions() { + return this.dependencies.bundledExtensions + .map(extension => { + try { + const LensExtensionClass = extension[this.dependencies.extensionEntryPointName](); + + if (!LensExtensionClass) { + return null; + } + + const installedExtension: InstalledExtension = { + absolutePath: "irrelevant", + id: extension.manifest.name, + isBundled: true, + isCompatible: true, + isEnabled: true, + manifest: extension.manifest, + manifestPath: "irrelevant", + }; + const instance = this.dependencies.createExtensionInstance( + LensExtensionClass, + installedExtension, + ); + + this.dependencies.extensionInstances.set(extension.manifest.name, instance); + + return { + instance, + installedExtension, + activated: instance.activate(), + } as ExtensionBeingActivated; + } catch (err) { + this.dependencies.logger.error(`${logModule}: error loading extension`, { ext: extension, err }); + + return null; + } + }) + .filter(isDefined); + } + + protected async loadExtensions(extensions: ExtensionBeingActivated[]): Promise { + // We first need to wait until each extension's `onActivate` is resolved or rejected, + // as this might register new catalog categories. Afterwards we can safely .enable the extension. + await Promise.all( + extensions.map(extension => + // If extension activation fails, log error + extension.activated.catch((error) => { + this.dependencies.logger.error(`${logModule}: activation extension error`, { ext: extension.installedExtension, error }); + }), + ), + ); + + extensions.forEach(({ instance }) => { + const extension = this.dependencies.getExtension(instance); + + extension.register(); + }); + + return extensions.map(extension => { + const loaded = extension.instance.enable().catch((err) => { + this.dependencies.logger.error(`${logModule}: failed to enable`, { ext: extension, err }); + }); + + return { + isBundled: extension.installedExtension.isBundled, + loaded, + }; + }); + } + + protected async loadUserExtensions(installedExtensions: Map) { + // Steps of the function: + // 1. require and call .activate for each Extension + // 2. Wait until every extension's onActivate has been resolved + // 3. Call .enable for each extension + // 4. Return ExtensionLoading[] + + return [...installedExtensions.entries()] + .map(([extId, extension]) => { + const alreadyInit = this.dependencies.extensionInstances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); + + if (extension.isCompatible && extension.isEnabled && !alreadyInit) { + try { + const LensExtensionClass = this.requireExtension(extension); + + if (!LensExtensionClass) { + this.nonInstancesByName.add(extension.manifest.name); + + return null; + } + + const instance = this.dependencies.createExtensionInstance( + LensExtensionClass, + extension, + ); + + this.dependencies.extensionInstances.set(extId, instance); + + return { + instance, + installedExtension: extension, + activated: instance.activate(), + } as ExtensionBeingActivated; + } catch (err) { + this.dependencies.logger.error(`${logModule}: error loading extension`, { ext: extension, err }); + } + } else if (!extension.isEnabled && alreadyInit) { + this.removeInstance(extId); + } + + return null; + }) + .filter(isDefined); + } + + async autoInitExtensions() { + this.dependencies.logger.info(`${logModule}: auto initializing extensions`); + + const bundledExtensions = await this.loadBundledExtensions(); + const userExtensions = await this.loadUserExtensions(this.toJSON()); + const loadedExtensions = await this.loadExtensions([ + ...bundledExtensions, + ...userExtensions, + ]); + + // Setup reaction to load extensions on JSON changes + reaction(() => this.toJSON(), installedExtensions => { + void (async () => { + const userExtensions = await this.loadUserExtensions(installedExtensions); + + await this.loadExtensions(userExtensions); + })(); + }); + + return loadedExtensions; + } + + protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null { + const extRelativePath = extension.manifest[this.dependencies.extensionEntryPointName]; + + if (!extRelativePath) { + return null; + } + + const extAbsolutePath = this.dependencies.joinPaths(this.dependencies.getDirnameOfPath(extension.manifestPath), extRelativePath); + + try { + return require(/* webpackIgnore: true */ extAbsolutePath).default; + } catch (error) { + const message = (error instanceof Error ? error.stack : undefined) || error; + + this.dependencies.logger.error(`${logModule}: can't load ${this.dependencies.extensionEntryPointName} for "${extension.manifest.name}": ${message}`, { extension }); + } + + return null; + } + + getExtension(extId: LensExtensionId) { + return this.extensions.get(extId); + } + + getInstanceById(extId: LensExtensionId) { + return this.dependencies.extensionInstances.get(extId); + } + + toJSON(): Map { + return toJS(this.extensions); + } +} diff --git a/packages/core/src/extensions/extension-loader/extension-registrator-injection-token.ts b/packages/core/src/extensions/extension-loader/extension-registrator-injection-token.ts new file mode 100644 index 0000000000..295d3b67a4 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/extension-registrator-injection-token.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { LensExtension } from "../lens-extension"; + +export type ExtensionRegistrator = (extension: LensExtension) => + Injectable[] | IComputedValue[]>; + +export const extensionRegistratorInjectionToken = getInjectionToken({ + id: "extension-registrator-token", +}); diff --git a/packages/core/src/extensions/extension-loader/extension/extension.injectable.ts b/packages/core/src/extensions/extension-loader/extension/extension.injectable.ts new file mode 100644 index 0000000000..6b9424cea4 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/extension/extension.injectable.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { difference, find, map } from "lodash"; +import { reaction, runInAction } from "mobx"; +import { disposer } from "../../../common/utils/disposer"; +import type { LensExtension } from "../../lens-extension"; +import { extensionRegistratorInjectionToken } from "../extension-registrator-injection-token"; + +export interface Extension { + register: () => void; + deregister: () => void; +} + +const idsToInjectables = (ids: string[], injectables: Injectable[]) => ids.map(id => { + const injectable = find(injectables, { id }); + + if (!injectable) { + throw new Error(`Injectable ${id} not found`); + } + + return injectable; +}); + +const extensionInjectable = getInjectable({ + id: "extension", + + instantiate: (parentDi, instance: LensExtension): Extension => { + const extensionInjectable = getInjectable({ + id: `extension-${instance.sanitizedExtensionId}`, + + instantiate: (childDi) => { + const extensionRegistrators = childDi.injectMany(extensionRegistratorInjectionToken); + const reactionDisposer = disposer(); + + return { + register: () => { + extensionRegistrators.forEach((getInjectablesOfExtension) => { + const injectables = getInjectablesOfExtension(instance); + + reactionDisposer.push( + // injectables is either an array or a computed array, in which case + // we need to update the registered injectables with a reaction every time they change + reaction( + () => Array.isArray(injectables) ? injectables : injectables.get(), + (currentInjectables, previousInjectables = []) => { + // Register new injectables and deregister removed injectables by id + const currentIds = map(currentInjectables, "id"); + const previousIds = map(previousInjectables, "id"); + const idsToAdd = difference(currentIds, previousIds); + const idsToRemove = previousIds.filter(previousId => !currentIds.includes(previousId)); + + if (idsToRemove.length > 0) { + childDi.deregister(...idsToInjectables(idsToRemove, previousInjectables)); + } + + if (idsToAdd.length > 0) { + childDi.register(...idsToInjectables(idsToAdd, currentInjectables)); + } + }, { + fireImmediately: true, + }, + )); + }); + }, + + deregister: () => { + reactionDisposer(); + + runInAction(() => { + parentDi.deregister(extensionInjectable); + }); + }, + }; + }, + }); + + runInAction(() => { + parentDi.register(extensionInjectable); + }); + + return parentDi.inject(extensionInjectable); + }, + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, instance: LensExtension) => instance, + }), +}); + +export default extensionInjectable; diff --git a/packages/core/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts new file mode 100644 index 0000000000..469ed85949 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/directory-for-extension-data.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import joinPathsInjectable from "../../../common/path/join-paths.injectable"; + +const directoryForExtensionDataInjectable = getInjectable({ + id: "directory-for-extension-data", + + instantiate: (di) => { + const joinPaths = di.inject(joinPathsInjectable); + const directoryForUserData = di.inject(directoryForUserDataInjectable); + + return joinPaths(directoryForUserData, "extension_data"); + }, +}); + +export default directoryForExtensionDataInjectable; diff --git a/packages/core/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable.ts b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable.ts new file mode 100644 index 0000000000..b511437da9 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { FileSystemProvisionerStore } from "./file-system-provisioner-store"; +import directoryForExtensionDataInjectable from "./directory-for-extension-data.injectable"; +import ensureDirectoryInjectable from "../../../common/fs/ensure-dir.injectable"; +import joinPathsInjectable from "../../../common/path/join-paths.injectable"; +import randomBytesInjectable from "../../../common/utils/random-bytes.injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import storeMigrationVersionInjectable from "../../../common/vars/store-migration-version.injectable"; +import { baseStoreIpcChannelPrefixesInjectionToken } from "../../../common/base-store/channel-prefix"; +import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../../../common/base-store/disable-sync"; +import { persistStateToConfigInjectionToken } from "../../../common/base-store/save-to-file"; +import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../../../common/utils/channel/enlist-message-channel-listener-injection-token"; + +const fileSystemProvisionerStoreInjectable = getInjectable({ + id: "file-system-provisioner-store", + + instantiate: (di) => new FileSystemProvisionerStore({ + directoryForExtensionData: di.inject(directoryForExtensionDataInjectable), + ensureDirectory: di.inject(ensureDirectoryInjectable), + joinPaths: di.inject(joinPathsInjectable), + randomBytes: di.inject(randomBytesInjectable), + directoryForUserData: di.inject(directoryForUserDataInjectable), + getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable), + logger: di.inject(loggerInjectable), + storeMigrationVersion: di.inject(storeMigrationVersionInjectable), + migrations: {}, + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken), + persistStateToConfig: di.inject(persistStateToConfigInjectionToken), + enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken), + shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), + }), +}); + +export default fileSystemProvisionerStoreInjectable; diff --git a/packages/core/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.ts b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.ts new file mode 100644 index 0000000000..5655a0cf06 --- /dev/null +++ b/packages/core/src/extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { SHA256 } from "crypto-js"; +import { action, makeObservable, observable } from "mobx"; +import type { BaseStoreDependencies } from "../../../common/base-store/base-store"; +import { BaseStore } from "../../../common/base-store/base-store"; +import type { LensExtensionId } from "../../lens-extension"; +import { getOrInsertWithAsync, toJS } from "../../../common/utils"; +import type { EnsureDirectory } from "../../../common/fs/ensure-dir.injectable"; +import type { JoinPaths } from "../../../common/path/join-paths.injectable"; +import type { RandomBytes } from "../../../common/utils/random-bytes.injectable"; + +interface FSProvisionModel { + extensions: Record; // extension names to paths +} + +interface Dependencies extends BaseStoreDependencies { + readonly directoryForExtensionData: string; + ensureDirectory: EnsureDirectory; + joinPaths: JoinPaths; + randomBytes: RandomBytes; +} + +export class FileSystemProvisionerStore extends BaseStore { + readonly registeredExtensions = observable.map(); + + constructor(protected readonly dependencies: Dependencies) { + super(dependencies, { + configName: "lens-filesystem-provisioner-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + }); + + makeObservable(this); + } + + /** + * This function retrieves the saved path to the folder which the extension + * can saves files to. If the folder is not present then it is created. + * @param extensionName the name of the extension requesting the path + * @returns path to the folder that the extension can safely write files to. + */ + async requestDirectory(extensionName: string): Promise { + const dirPath = await getOrInsertWithAsync(this.registeredExtensions, extensionName, async () => { + const salt = (await this.dependencies.randomBytes(32)).toString("hex"); + const hashedName = SHA256(`${extensionName}/${salt}`).toString(); + + return this.dependencies.joinPaths(this.dependencies.directoryForExtensionData, hashedName); + }); + + await this.dependencies.ensureDirectory(dirPath); + + return dirPath; + } + + @action + protected fromStore({ extensions }: FSProvisionModel = { extensions: {}}): void { + this.registeredExtensions.merge(extensions); + } + + toJSON(): FSProvisionModel { + return toJS({ + extensions: Object.fromEntries(this.registeredExtensions), + }); + } +} diff --git a/packages/core/src/extensions/extension-loader/index.ts b/packages/core/src/extensions/extension-loader/index.ts new file mode 100644 index 0000000000..a1dab46d3b --- /dev/null +++ b/packages/core/src/extensions/extension-loader/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./extension-loader"; diff --git a/packages/core/src/extensions/extension-loader/update-extensions-state/update-extensions-state.injectable.ts b/packages/core/src/extensions/extension-loader/update-extensions-state/update-extensions-state.injectable.ts new file mode 100644 index 0000000000..754375dd5a --- /dev/null +++ b/packages/core/src/extensions/extension-loader/update-extensions-state/update-extensions-state.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import extensionsStoreInjectable from "../../extensions-store/extensions-store.injectable"; + +const updateExtensionsStateInjectable = getInjectable({ + id: "update-extensions-state", + instantiate: (di) => di.inject(extensionsStoreInjectable).mergeState, +}); + +export default updateExtensionsStateInjectable; diff --git a/packages/core/src/extensions/extension-packages-root/extension-packages-root.injectable.ts b/packages/core/src/extensions/extension-packages-root/extension-packages-root.injectable.ts new file mode 100644 index 0000000000..81e91c5e27 --- /dev/null +++ b/packages/core/src/extensions/extension-packages-root/extension-packages-root.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable + from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; + +const extensionPackagesRootInjectable = getInjectable({ + id: "extension-packages-root", + instantiate: (di) => di.inject(directoryForUserDataInjectable), +}); + +export default extensionPackagesRootInjectable; diff --git a/packages/core/src/extensions/extension-store.ts b/packages/core/src/extensions/extension-store.ts new file mode 100644 index 0000000000..271175f0a4 --- /dev/null +++ b/packages/core/src/extensions/extension-store.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { BaseStoreParams } from "../common/base-store/base-store"; +import { BaseStore } from "../common/base-store/base-store"; +import * as path from "path"; +import type { LensExtension } from "./lens-extension"; +import assert from "assert"; +import type { StaticThis } from "../common/utils"; +import { getOrInsertWith } from "../common/utils"; +import { getLegacyGlobalDiForExtensionApi } from "./as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import directoryForUserDataInjectable from "../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import getConfigurationFileModelInjectable from "../common/get-configuration-file-model/get-configuration-file-model.injectable"; +import loggerInjectable from "../common/logger.injectable"; +import storeMigrationVersionInjectable from "../common/vars/store-migration-version.injectable"; +import type { Migrations } from "conf/dist/source/types"; +import { baseStoreIpcChannelPrefixesInjectionToken } from "../common/base-store/channel-prefix"; +import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../common/base-store/disable-sync"; +import { persistStateToConfigInjectionToken } from "../common/base-store/save-to-file"; +import getBasenameOfPathInjectable from "../common/path/get-basename.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../common/utils/channel/enlist-message-channel-listener-injection-token"; + +export interface ExtensionStoreParams extends BaseStoreParams { + migrations?: Migrations; +} + +export abstract class ExtensionStore extends BaseStore { + private static readonly instances = new WeakMap(); + + /** + * @deprecated This is a form of global shared state. Just call `new Store(...)` + */ + static createInstance(this: StaticThis, ...args: R): T { + return getOrInsertWith(ExtensionStore.instances, this, () => new this(...args)) as T; + } + + /** + * @deprecated This is a form of global shared state. Just call `new Store(...)` + */ + static getInstance(this: StaticThis, strict?: true): T; + static getInstance(this: StaticThis, strict: false): T | undefined; + static getInstance(this: StaticThis, strict = true): T | undefined { + if (!ExtensionStore.instances.has(this) && strict) { + throw new TypeError(`instance of ${this.name} is not created`); + } + + return ExtensionStore.instances.get(this) as (T | undefined); + } + + constructor({ migrations, ...params }: ExtensionStoreParams) { + const di = getLegacyGlobalDiForExtensionApi(); + + super({ + directoryForUserData: di.inject(directoryForUserDataInjectable), + getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable), + logger: di.inject(loggerInjectable), + storeMigrationVersion: di.inject(storeMigrationVersionInjectable), + migrations: migrations as Migrations>, + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken), + persistStateToConfig: di.inject(persistStateToConfigInjectionToken), + enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken), + shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), + }, params); + } + + /** + * @deprecated This is a form of global shared state. Just call `new Store(...)` + */ + static resetInstance() { + ExtensionStore.instances.delete(this); + } + + protected extension?: LensExtension; + + loadExtension(extension: LensExtension) { + this.extension = extension; + + this.params.projectVersion ??= this.extension.version; + + return super.load(); + } + + load() { + if (!this.extension) { return; } + + return super.load(); + } + + protected cwd() { + assert(this.extension, "must call this.load() first"); + + return path.join(super.cwd(), "extension-store", this.extension.name); + } +} diff --git a/packages/core/src/extensions/extensions-store/extensions-store.injectable.ts b/packages/core/src/extensions/extensions-store/extensions-store.injectable.ts new file mode 100644 index 0000000000..9f5ff83270 --- /dev/null +++ b/packages/core/src/extensions/extensions-store/extensions-store.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { baseStoreIpcChannelPrefixesInjectionToken } from "../../common/base-store/channel-prefix"; +import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../../common/base-store/disable-sync"; +import { persistStateToConfigInjectionToken } from "../../common/base-store/save-to-file"; +import getConfigurationFileModelInjectable from "../../common/get-configuration-file-model/get-configuration-file-model.injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import storeMigrationVersionInjectable from "../../common/vars/store-migration-version.injectable"; +import { ExtensionsStore } from "./extensions-store"; + +const extensionsStoreInjectable = getInjectable({ + id: "extensions-store", + instantiate: (di) => new ExtensionsStore({ + directoryForUserData: di.inject(directoryForUserDataInjectable), + getConfigurationFileModel: di.inject(getConfigurationFileModelInjectable), + logger: di.inject(loggerInjectable), + storeMigrationVersion: di.inject(storeMigrationVersionInjectable), + migrations: {}, + getBasenameOfPath: di.inject(getBasenameOfPathInjectable), + ipcChannelPrefixes: di.inject(baseStoreIpcChannelPrefixesInjectionToken), + persistStateToConfig: di.inject(persistStateToConfigInjectionToken), + enlistMessageChannelListener: di.inject(enlistMessageChannelListenerInjectionToken), + shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), + }), +}); + +export default extensionsStoreInjectable; diff --git a/packages/core/src/extensions/extensions-store/extensions-store.ts b/packages/core/src/extensions/extensions-store/extensions-store.ts new file mode 100644 index 0000000000..3b2dc80eb1 --- /dev/null +++ b/packages/core/src/extensions/extensions-store/extensions-store.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { LensExtensionId } from "../lens-extension"; +import { action, computed, makeObservable, observable } from "mobx"; +import { toJS } from "../../common/utils"; +import type { BaseStoreDependencies } from "../../common/base-store/base-store"; +import { BaseStore } from "../../common/base-store/base-store"; + +export interface LensExtensionsStoreModel { + extensions: Record; +} + +export interface LensExtensionState { + enabled?: boolean; + name: string; +} + +export interface IsEnabledExtensionDescriptor { + id: string; + isBundled: boolean; +} + +export class ExtensionsStore extends BaseStore { + constructor(deps: BaseStoreDependencies) { + super(deps, { + configName: "lens-extensions", + }); + makeObservable(this); + this.load(); + } + + @computed + get enabledExtensions() { + return Array.from(this.state.values()) + .filter(({ enabled }) => enabled) + .map(({ name }) => name); + } + + protected state = observable.map(); + + isEnabled({ id, isBundled }: IsEnabledExtensionDescriptor): boolean { + // By default false, so that copied extensions are disabled by default. + // If user installs the extension from the UI, the Extensions component will specifically enable it. + return isBundled || Boolean(this.state.get(id)?.enabled); + } + + mergeState = action((extensionsState: Record | [LensExtensionId, LensExtensionState][]) => { + this.state.merge(extensionsState); + }); + + @action + protected fromStore({ extensions }: LensExtensionsStoreModel) { + this.state.merge(extensions); + } + + toJSON(): LensExtensionsStoreModel { + return toJS({ + extensions: Object.fromEntries(this.state), + }); + } +} diff --git a/packages/core/src/extensions/extensions.injectable.ts b/packages/core/src/extensions/extensions.injectable.ts new file mode 100644 index 0000000000..7cc019a318 --- /dev/null +++ b/packages/core/src/extensions/extensions.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import extensionInstancesInjectable from "./extension-loader/extension-instances.injectable"; + +const extensionsInjectable = getInjectable({ + id: "extensions", + instantiate: (di) => { + const extensionInstances = di.inject(extensionInstancesInjectable); + + return computed(() => [...extensionInstances.values()].filter(extension => extension.isEnabled)); + }, +}); + +export default extensionsInjectable; diff --git a/packages/core/src/extensions/ipc/ipc-main.ts b/packages/core/src/extensions/ipc/ipc-main.ts new file mode 100644 index 0000000000..cd18bcc56c --- /dev/null +++ b/packages/core/src/extensions/ipc/ipc-main.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { ipcMain } from "electron"; +import { IpcPrefix, IpcRegistrar } from "./ipc-registrar"; +import { Disposers, lensExtensionDependencies } from "../lens-extension"; +import type { LensMainExtension } from "../lens-main-extension"; +import type { Disposer } from "../../common/utils"; +import { once } from "lodash"; +import { ipcMainHandle } from "../../common/ipc"; + +export abstract class IpcMain extends IpcRegistrar { + constructor(extension: LensMainExtension) { + super(extension); + + // Call the static method on the bottom child class. + extension[Disposers].push(() => (this.constructor as typeof IpcMain).resetInstance()); + } + + /** + * Listen for broadcasts within your extension + * @param channel The channel to listen for broadcasts on + * @param listener The function that will be called with the arguments of the broadcast + * @returns An optional disposer, Lens will cleanup when the extension is disabled or uninstalled even if this is not called + */ + listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer { + const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; + const cleanup = once(() => { + this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: removing extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }}); + + return ipcMain.removeListener(prefixedChannel, listener); + }); + + this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: adding extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }}); + ipcMain.addListener(prefixedChannel, listener); + this.extension[Disposers].push(cleanup); + + return cleanup; + } + + /** + * Declare a RPC over `channel`. Lens will cleanup when the extension is disabled or uninstalled + * @param channel The name of the RPC + * @param handler The remote procedure that is called + */ + handle(channel: string, handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any): void { + const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; + + this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: adding extension handler`, { channel, extension: { name: this.extension.name, version: this.extension.version }}); + ipcMainHandle(prefixedChannel, handler); + this.extension[Disposers].push(() => { + this.extension[lensExtensionDependencies].logger.info(`[IPC-RENDERER]: removing extension handler`, { channel, extension: { name: this.extension.name, version: this.extension.version }}); + + return ipcMain.removeHandler(prefixedChannel); + }); + } +} diff --git a/packages/core/src/extensions/ipc/ipc-registrar.ts b/packages/core/src/extensions/ipc/ipc-registrar.ts new file mode 100644 index 0000000000..04222bd64b --- /dev/null +++ b/packages/core/src/extensions/ipc/ipc-registrar.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { Singleton } from "../../common/utils"; +import type { LensExtension } from "../lens-extension"; +import { createHash } from "crypto"; +import { broadcastMessage } from "../../common/ipc"; + +export const IpcPrefix = Symbol(); + +export abstract class IpcRegistrar extends Singleton { + readonly [IpcPrefix]: string; + + constructor(protected readonly extension: LensExtension) { + super(); + this[IpcPrefix] = createHash("sha256").update(extension.id).digest("hex"); + } + + /** + * + * @param channel The channel to broadcast to your whole extension, both `main` and `renderer` + * @param args The arguments passed to all listeners + */ + broadcast(channel: string, ...args: any[]): void { + broadcastMessage(`extensions@${this[IpcPrefix]}:${channel}`, ...args); + } +} diff --git a/packages/core/src/extensions/ipc/ipc-renderer.ts b/packages/core/src/extensions/ipc/ipc-renderer.ts new file mode 100644 index 0000000000..71b67779f5 --- /dev/null +++ b/packages/core/src/extensions/ipc/ipc-renderer.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { ipcRenderer } from "electron"; +import { IpcPrefix, IpcRegistrar } from "./ipc-registrar"; +import { Disposers } from "../lens-extension"; +import type { LensRendererExtension } from "../lens-renderer-extension"; +import type { Disposer } from "../../common/utils"; +import { once } from "lodash"; + +export abstract class IpcRenderer extends IpcRegistrar { + constructor(extension: LensRendererExtension) { + super(extension); + + // Call the static method on the bottom child class. + extension[Disposers].push(() => (this.constructor as typeof IpcRenderer).resetInstance()); + } + + /** + * Listen for broadcasts within your extension. + * If the lifetime of the listener should be tied to the mounted lifetime of + * a component then putting the returned value in a `disposeOnUnmount` call will suffice. + * @param channel The channel to listen for broadcasts on + * @param listener The function that will be called with the arguments of the broadcast + * @returns An optional disposer, Lens will cleanup even if this is not called + */ + listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer { + const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; + const cleanup = once(() => { + console.info(`[IPC-RENDERER]: removing extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }}); + + return ipcRenderer.removeListener(prefixedChannel, listener); + }); + + console.info(`[IPC-RENDERER]: adding extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version }}); + ipcRenderer.addListener(prefixedChannel, listener); + this.extension[Disposers].push(cleanup); + + return cleanup; + } + + /** + * Request main to execute its function over the `channel` channel. + * This function only interacts with functions registered via `Ipc.IpcMain.handleRpc` + * An error will be thrown if no function has been registered on `main` with this channel ID. + * @param channel The channel to invoke a RPC on + * @param args The arguments to pass to the RPC + * @returns A promise of the resulting value + */ + invoke(channel: string, ...args: any[]): Promise { + const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; + + return ipcRenderer.invoke(prefixedChannel, ...args); + } +} diff --git a/packages/core/src/extensions/lens-extension-set-dependencies.ts b/packages/core/src/extensions/lens-extension-set-dependencies.ts new file mode 100644 index 0000000000..7b7c62597a --- /dev/null +++ b/packages/core/src/extensions/lens-extension-set-dependencies.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { IComputedValue } from "mobx"; +import type { CatalogCategoryRegistry } from "../common/catalog"; +import type { NavigateToRoute } from "../common/front-end-routing/navigate-to-route-injection-token"; +import type { Route } from "../common/front-end-routing/front-end-route-injection-token"; +import type { CatalogEntityRegistry as MainCatalogEntityRegistry } from "../main/catalog"; +import type { CatalogEntityRegistry as RendererCatalogEntityRegistry } from "../renderer/api/catalog/entity/registry"; +import type { GetExtensionPageParameters } from "../renderer/routes/get-extension-page-parameters.injectable"; +import type { FileSystemProvisionerStore } from "./extension-loader/file-system-provisioner-store/file-system-provisioner-store"; +import type { NavigateForExtension } from "../main/start-main-application/lens-window/navigate-for-extension.injectable"; +import type { Logger } from "../common/logger"; + +export interface LensExtensionDependencies { + readonly fileSystemProvisionerStore: FileSystemProvisionerStore; + readonly logger: Logger; +} + +export interface LensMainExtensionDependencies extends LensExtensionDependencies { + readonly entityRegistry: MainCatalogEntityRegistry; + readonly navigate: NavigateForExtension; +} + +export interface LensRendererExtensionDependencies extends LensExtensionDependencies { + navigateToRoute: NavigateToRoute; + getExtensionPageParameters: GetExtensionPageParameters; + readonly routes: IComputedValue[]>; + readonly entityRegistry: RendererCatalogEntityRegistry; + readonly categoryRegistry: CatalogCategoryRegistry; +} diff --git a/packages/core/src/extensions/lens-extension.ts b/packages/core/src/extensions/lens-extension.ts new file mode 100644 index 0000000000..831822fd2b --- /dev/null +++ b/packages/core/src/extensions/lens-extension.ts @@ -0,0 +1,142 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { InstalledExtension } from "./extension-discovery/extension-discovery"; +import { action, computed, makeObservable, observable } from "mobx"; +import type { PackageJson } from "type-fest"; +import { disposer } from "../common/utils"; +import type { LensExtensionDependencies } from "./lens-extension-set-dependencies"; +import type { ProtocolHandlerRegistration } from "./common-api/registrations"; + +export type LensExtensionId = string; // path to manifest (package.json) +export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; + +export interface LensExtensionManifest extends PackageJson { + name: string; + version: string; + main?: string; // path to %ext/dist/main.js + renderer?: string; // path to %ext/dist/renderer.js + /** + * Supported Lens version engine by extension could be defined in `manifest.engines.lens` + * Only MAJOR.MINOR version is taken in consideration. + */ + engines: { + lens: string; // "semver"-package format + npm?: string; + node?: string; + }; +} + +export const lensExtensionDependencies = Symbol("lens-extension-dependencies"); +export const Disposers = Symbol("disposers"); + +export class LensExtension { + readonly id: LensExtensionId; + readonly manifest: LensExtensionManifest; + readonly manifestPath: string; + readonly isBundled: boolean; + + get sanitizedExtensionId() { + return sanitizeExtensionName(this.name); + } + + protocolHandlers: ProtocolHandlerRegistration[] = []; + + @observable private _isEnabled = false; + + @computed get isEnabled() { + return this._isEnabled; + } + + [Disposers] = disposer(); + + constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { + makeObservable(this); + this.id = id; + this.manifest = manifest; + this.manifestPath = manifestPath; + this.isBundled = !!isBundled; + } + + get name() { + return this.manifest.name; + } + + get version() { + return this.manifest.version; + } + + get description() { + return this.manifest.description; + } + + readonly [lensExtensionDependencies]!: Dependencies; + + /** + * getExtensionFileFolder returns the path to an already created folder. This + * folder is for the sole use of this extension. + * + * Note: there is no security done on this folder, only obfuscation of the + * folder name. + */ + async getExtensionFileFolder(): Promise { + return this[lensExtensionDependencies].fileSystemProvisionerStore.requestDirectory(this.id); + } + + @action + async enable() { + if (this._isEnabled) { + return; + } + + this._isEnabled = true; + this[lensExtensionDependencies].logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); + } + + @action + async disable() { + if (!this._isEnabled) { + return; + } + + this._isEnabled = false; + + try { + await this.onDeactivate(); + this[Disposers](); + this[lensExtensionDependencies].logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`); + } catch (error) { + this[lensExtensionDependencies].logger.error(`[EXTENSION]: disabling ${this.name}@${this.version} threw an error: ${error}`); + } + } + + async activate(): Promise { + return this.onActivate(); + } + + protected onActivate(): Promise | void { + return; + } + + protected onDeactivate(): Promise | void { + return; + } +} + +export function sanitizeExtensionName(name: string) { + return name.replace("@", "").replace("/", "--"); +} + +export function getSanitizedPath(...parts: string[]) { + return parts + .filter(Boolean) + .join("/") + .replace(/\/+/g, "/") + .replace(/\/$/, ""); +} // normalize multi-slashes (e.g. coming from page.id) + +export function extensionDisplayName(name: string, version: string) { + return `${name}@${version}`; +} diff --git a/packages/core/src/extensions/lens-main-extension.ts b/packages/core/src/extensions/lens-main-extension.ts new file mode 100644 index 0000000000..b97d8da834 --- /dev/null +++ b/packages/core/src/extensions/lens-main-extension.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { LensExtension, lensExtensionDependencies } from "./lens-extension"; +import type { CatalogEntity } from "../common/catalog"; +import type { IComputedValue, IObservableArray } from "mobx"; +import type { MenuRegistration } from "../features/application-menu/main/menu-registration"; +import type { TrayMenuRegistration } from "../main/tray/tray-menu-registration"; +import type { ShellEnvModifier } from "../main/shell-session/shell-env-modifier/shell-env-modifier-registration"; +import type { LensMainExtensionDependencies } from "./lens-extension-set-dependencies"; + +export class LensMainExtension extends LensExtension { + appMenus: MenuRegistration[] = []; + trayMenus: TrayMenuRegistration[] | IComputedValue = []; + + /** + * implement this to modify the shell environment that Lens terminals are opened with. The ShellEnvModifier type has the signature + * + * (ctx: ShellEnvContext, env: Record) => Record + * + * @param ctx the shell environment context, specifically the relevant catalog entity for the terminal. This can be used, for example, to get + * cluster-specific information that can be made available in the shell environment by the implementation of terminalShellEnvModifier + * + * @param env the current shell environment that the terminal will be opened with. The implementation should modify this as desired. + * + * @returns the modified shell environment that the terminal will be opened with. The implementation must return env as passed in, if it + * does not modify the shell environment + */ + terminalShellEnvModifier?: ShellEnvModifier; + + async navigate(pageId?: string, params?: Record, frameId?: number) { + await this[lensExtensionDependencies].navigate(this.id, pageId, params, frameId); + } + + addCatalogSource(id: string, source: IObservableArray) { + this[lensExtensionDependencies].entityRegistry.addObservableSource(`${this.name}:${id}`, source); + } + + removeCatalogSource(id: string) { + this[lensExtensionDependencies].entityRegistry.removeSource(`${this.name}:${id}`); + } +} diff --git a/packages/core/src/extensions/lens-renderer-extension.ts b/packages/core/src/extensions/lens-renderer-extension.ts new file mode 100644 index 0000000000..b1b024c091 --- /dev/null +++ b/packages/core/src/extensions/lens-renderer-extension.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { Disposers, LensExtension, lensExtensionDependencies } from "./lens-extension"; +import type { CatalogEntity, CategoryFilter } from "../common/catalog"; +import type { Disposer } from "../common/utils"; +import type { EntityFilter } from "../renderer/api/catalog/entity/registry"; +import type { TopBarRegistration } from "../renderer/components/layout/top-bar/top-bar-registration"; +import type { KubernetesCluster } from "../common/catalog-entities"; +import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; +import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; +import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; +import type { AppPreferenceRegistration } from "../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-registration"; +import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns"; +import type { CustomCategoryViewRegistration } from "../renderer/components/+catalog/custom-views"; +import type { StatusBarRegistration } from "../renderer/components/status-bar/status-bar-registration"; +import type { KubeObjectMenuRegistration } from "../renderer/components/kube-object-menu/kube-object-menu-registration"; +import type { WorkloadsOverviewDetailRegistration } from "../renderer/components/+workloads-overview/workloads-overview-detail-registration"; +import type { KubeObjectStatusRegistration } from "../renderer/components/kube-object-status-icon/kube-object-status-registration"; +import { fromPairs, map, matches, toPairs } from "lodash/fp"; +import { pipeline } from "@ogre-tools/fp"; +import { getExtensionRoutePath } from "../renderer/routes/for-extension"; +import type { LensRendererExtensionDependencies } from "./lens-extension-set-dependencies"; +import type { KubeObjectHandlerRegistration } from "../renderer/kube-object/handler"; +import type { AppPreferenceTabRegistration } from "../features/preferences/renderer/compliance-for-legacy-extension-api/app-preference-tab-registration"; +import type { KubeObjectDetailRegistration } from "../renderer/components/kube-object-details/kube-object-detail-registration"; +import type { ClusterFrameChildComponent } from "../renderer/frames/cluster-frame/cluster-frame-child-component-injection-token"; +import type { EntitySettingRegistration } from "../renderer/components/+entity-settings/extension-registrator.injectable"; +import type { CatalogEntityDetailRegistration } from "../renderer/components/+catalog/entity-details/token"; +import type { ClusterPageMenuRegistration, PageRegistration } from "./common-api/registrations"; + +export class LensRendererExtension extends LensExtension { + globalPages: PageRegistration[] = []; + clusterPages: PageRegistration[] = []; + clusterPageMenus: ClusterPageMenuRegistration[] = []; + clusterFrameComponents: ClusterFrameChildComponent[] = []; + kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; + appPreferences: AppPreferenceRegistration[] = []; + appPreferenceTabs: AppPreferenceTabRegistration[] = []; + entitySettings: EntitySettingRegistration[] = []; + statusBarItems: StatusBarRegistration[] = []; + kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; + kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; + kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = []; + commands: CommandRegistration[] = []; + welcomeMenus: WelcomeMenuRegistration[] = []; + welcomeBanners: WelcomeBannerRegistration[] = []; + catalogEntityDetailItems: CatalogEntityDetailRegistration[] = []; + topBarItems: TopBarRegistration[] = []; + additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = []; + customCategoryViews: CustomCategoryViewRegistration[] = []; + kubeObjectHandlers: KubeObjectHandlerRegistration[] = []; + + async navigate(pageId?: string, params: object = {}) { + const routes = this[lensExtensionDependencies].routes.get(); + const targetRegistration = [...this.globalPages, ...this.clusterPages] + .find(registration => registration.id === (pageId || undefined)); + + if (!targetRegistration) { + return; + } + + const targetRoutePath = getExtensionRoutePath(this, targetRegistration.id); + const targetRoute = routes.find(matches({ path: targetRoutePath })); + + if (!targetRoute) { + return; + } + + const normalizedParams = this[lensExtensionDependencies].getExtensionPageParameters({ + extension: this, + registration: targetRegistration, + }); + const query = pipeline( + params, + toPairs, + map(([key, value]) => [ + key, + normalizedParams[key].stringify(value), + ]), + fromPairs, + ); + + this[lensExtensionDependencies].navigateToRoute(targetRoute, { + query, + }); + } + + /** + * Defines if extension is enabled for a given cluster. This method is only + * called when the extension is created within a cluster frame. + * + * The default implementation is to return `true` + * + * @deprecated Switch to using "enabled" or "visible" properties in each registration together with `activeCluster` + */ + async isEnabledForCluster(cluster: KubernetesCluster): Promise { + return (void cluster) || true; + } + + /** + * Add a filtering function for the catalog entities. This will be removed if the extension is disabled. + * @param fn The function which should return a truthy value for those entities which should be kept. + * @returns A function to clean up the filter + */ + addCatalogFilter(fn: EntityFilter): Disposer { + const dispose = this[lensExtensionDependencies].entityRegistry.addCatalogFilter(fn); + + this[Disposers].push(dispose); + + return dispose; + } + + /** + * Add a filtering function for the catalog categories. This will be removed if the extension is disabled. + * @param fn The function which should return a truthy value for those categories which should be kept. + * @returns A function to clean up the filter + */ + addCatalogCategoryFilter(fn: CategoryFilter): Disposer { + const dispose = this[lensExtensionDependencies].categoryRegistry.addCatalogCategoryFilter(fn); + + this[Disposers].push(dispose); + + return dispose; + } +} diff --git a/packages/core/src/extensions/main-api/catalog.ts b/packages/core/src/extensions/main-api/catalog.ts new file mode 100644 index 0000000000..07adbe9d4b --- /dev/null +++ b/packages/core/src/extensions/main-api/catalog.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CatalogEntity } from "../../common/catalog"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable"; +import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable"; + +export const catalogCategories = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable); +const catalogEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable); + +export interface CatalogEntityRegistry { + getItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[]; + /** + * @deprecated use a cast instead of a unbounded type parameter + */ + getItemsForApiKind(apiVersion: string, kind: string): T[]; +} + +export const catalogEntities: CatalogEntityRegistry = { + getItemsForApiKind(apiVersion: string, kind: string) { + return catalogEntityRegistry.filterItemsForApiKind(apiVersion, kind); + }, +}; diff --git a/packages/core/src/extensions/main-api/index.ts b/packages/core/src/extensions/main-api/index.ts new file mode 100644 index 0000000000..9f39a4c781 --- /dev/null +++ b/packages/core/src/extensions/main-api/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import * as Catalog from "./catalog"; +import * as Navigation from "./navigation"; +import * as K8sApi from "./k8s-api"; +import * as Power from "./power"; +import { IpcMain as Ipc } from "../ipc/ipc-main"; +import { LensMainExtension as LensExtension } from "../lens-main-extension"; + +export { + Catalog, + Navigation, + K8sApi, + Ipc, + LensExtension, + Power, +}; diff --git a/packages/core/src/extensions/main-api/k8s-api.ts b/packages/core/src/extensions/main-api/k8s-api.ts new file mode 100644 index 0000000000..05cffffdbf --- /dev/null +++ b/packages/core/src/extensions/main-api/k8s-api.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * @deprecated This function never works + * @returns false + */ +export function isAllowedResource(...args: any[]) { + return Boolean(void args); +} + +export * from "../common-api/k8s-api"; diff --git a/packages/core/src/extensions/main-api/navigation.ts b/packages/core/src/extensions/main-api/navigation.ts new file mode 100644 index 0000000000..375d2bac74 --- /dev/null +++ b/packages/core/src/extensions/main-api/navigation.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { + Environments, + getEnvironmentSpecificLegacyGlobalDiForExtensionApi, +} from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; + +import navigateInjectable from "../../main/start-main-application/lens-window/navigate.injectable"; + +export function navigate(url: string) { + const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.main); + + const navigate = di.inject(navigateInjectable); + + return navigate(url); +} diff --git a/packages/core/src/extensions/main-api/power.ts b/packages/core/src/extensions/main-api/power.ts new file mode 100644 index 0000000000..671308798a --- /dev/null +++ b/packages/core/src/extensions/main-api/power.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { powerMonitor } from "electron"; +import type { Disposer } from "../../common/utils/disposer"; + +/** + * Event listener for system power events + */ +export type PowerEventListener = () => void; + +/** + * Adds event listener to system suspend events + * @param listener function which will be called on system suspend + * @returns function to remove event listener + */ +export const onSuspend = (listener: PowerEventListener): Disposer => { + powerMonitor.on("suspend", listener); + + return () => { + powerMonitor.off("suspend", listener); + }; +}; + +/** + * Adds event listener to system resume event + * @param listener function which will be called on system resume + * @returns function to remove event listener + */ +export const onResume = (listener: PowerEventListener): Disposer => { + powerMonitor.on("resume", listener); + + return () => { + powerMonitor.off("resume", listener); + }; +}; + +/** + * Adds event listener to the event which is emitted when + * the system is about to reboot or shut down + * @param listener function which will be called on system shutdown + * @returns function to remove event listener + */ +export const onShutdown = (listener: PowerEventListener): Disposer => { + powerMonitor.on("shutdown", listener); + + return () => { + powerMonitor.off("shutdown", listener); + }; +}; diff --git a/packages/core/src/extensions/main-extensions.injectable.ts b/packages/core/src/extensions/main-extensions.injectable.ts new file mode 100644 index 0000000000..07010af010 --- /dev/null +++ b/packages/core/src/extensions/main-extensions.injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import extensionsInjectable from "./extensions.injectable"; +import type { LensMainExtension } from "./lens-main-extension"; + +const mainExtensionsInjectable = getInjectable({ + id: "main-extensions", + + instantiate: (di) => + di.inject(extensionsInjectable) as IComputedValue, +}); + +export default mainExtensionsInjectable; diff --git a/packages/core/src/extensions/renderer-api/catalog.ts b/packages/core/src/extensions/renderer-api/catalog.ts new file mode 100644 index 0000000000..43b840a39a --- /dev/null +++ b/packages/core/src/extensions/renderer-api/catalog.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +import type { CatalogCategory, CatalogEntity } from "../../common/catalog"; +import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog/entity/registry"; +import type { Disposer } from "../../common/utils"; +import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import catalogEntityRegistryInjectable from "../../renderer/api/catalog/entity/registry.injectable"; +import activeKubernetesClusterInjectable from "../../renderer/cluster-frame-context/active-kubernetes-cluster.injectable"; + +export const catalogCategories = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable); + +const internalEntityRegistry = asLegacyGlobalForExtensionApi(catalogEntityRegistryInjectable); + +export class CatalogEntityRegistry { + /** + * Currently active/visible entity + */ + get activeEntity() { + return internalEntityRegistry.activeEntity; + } + + get entities(): Map { + return internalEntityRegistry.entities; + } + + getById(id: string) { + return this.entities.get(id); + } + + getItemsForApiKind(apiVersion: string, kind: string): T[] { + return internalEntityRegistry.getItemsForApiKind(apiVersion, kind); + } + + getItemsForCategory(category: CatalogCategory): T[] { + return internalEntityRegistry.getItemsForCategory(category); + } + + /** + * Add a onBeforeRun hook to a catalog entities. If `onBeforeRun` was previously + * added then it will not be added again. + * @param onBeforeRun The function to be called with a `CatalogRunEvent` + * event target will be the catalog entity. onBeforeRun hook can call event.preventDefault() + * to stop run sequence + * @returns A function to remove that hook + */ + addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer { + return internalEntityRegistry.addOnBeforeRun(onBeforeRun); + } +} + +export const catalogEntities = new CatalogEntityRegistry(); + +export const activeCluster = asLegacyGlobalForExtensionApi( + activeKubernetesClusterInjectable, +); diff --git a/packages/core/src/extensions/renderer-api/components.ts b/packages/core/src/extensions/renderer-api/components.ts new file mode 100644 index 0000000000..dc8c52053d --- /dev/null +++ b/packages/core/src/extensions/renderer-api/components.ts @@ -0,0 +1,173 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import createTerminalTabInjectable from "../../renderer/components/dock/terminal/create-terminal-tab.injectable"; +import terminalStoreInjectable from "../../renderer/components/dock/terminal/store.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import logTabStoreInjectable from "../../renderer/components/dock/logs/tab-store.injectable"; + +import commandOverlayInjectable from "../../renderer/components/command-palette/command-overlay.injectable"; +import createPodLogsTabInjectable from "../../renderer/components/dock/logs/create-pod-logs-tab.injectable"; +import createWorkloadLogsTabInjectable from "../../renderer/components/dock/logs/create-workload-logs-tab.injectable"; +import sendCommandInjectable from "../../renderer/components/dock/terminal/send-command.injectable"; +import renameTabInjectable from "../../renderer/components/dock/dock/rename-tab.injectable"; +import { asLegacyGlobalObjectForExtensionApiWithModifications } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications"; +import { ConfirmDialog as _ConfirmDialog } from "../../renderer/components/confirm-dialog"; +import type { ConfirmDialogBooleanParams, ConfirmDialogParams, ConfirmDialogProps } from "../../renderer/components/confirm-dialog"; +import openConfirmDialogInjectable from "../../renderer/components/confirm-dialog/open.injectable"; +import confirmInjectable from "../../renderer/components/confirm-dialog/confirm.injectable"; +import notificationsStoreInjectable from "../../renderer/components/notifications/notifications-store.injectable"; +import podStoreInjectable from "../../renderer/components/+workloads-pods/store.injectable"; +import getDetailsUrlInjectable from "../../renderer/components/kube-detail-params/get-details-url.injectable"; +import showDetailsInjectable from "../../renderer/components/kube-detail-params/show-details.injectable"; +import showCheckedErrorNotificationInjectable from "../../renderer/components/notifications/show-checked-error.injectable"; +import showErrorNotificationInjectable from "../../renderer/components/notifications/show-error-notification.injectable"; +import showInfoNotificationInjectable from "../../renderer/components/notifications/show-info-notification.injectable"; +import showShortInfoNotificationInjectable from "../../renderer/components/notifications/show-short-info.injectable"; +import showSuccessNotificationInjectable from "../../renderer/components/notifications/show-success-notification.injectable"; + +// layouts +export * from "../../renderer/components/layout/main-layout"; +export * from "../../renderer/components/layout/setting-layout"; +export * from "../../renderer/components/layout/page-layout"; +export * from "../../renderer/components/layout/wizard-layout"; +export * from "../../renderer/components/layout/tab-layout"; + +// form-controls +export * from "../../renderer/components/button"; +export * from "../../renderer/components/checkbox"; +export * from "../../renderer/components/radio"; +export * from "../../renderer/components/select"; +export * from "../../renderer/components/slider"; +export * from "../../renderer/components/switch"; +export * from "../../renderer/components/input/input"; + +// command-overlay +export const CommandOverlay = asLegacyGlobalForExtensionApi(commandOverlayInjectable); + +export type { + CategoryColumnRegistration, + AdditionalCategoryColumnRegistration, +} from "../../renderer/components/+catalog/custom-category-columns"; + +// other components +export type { + ConfirmDialogBooleanParams, + ConfirmDialogParams, + ConfirmDialogProps, +}; +export const ConfirmDialog = Object.assign(_ConfirmDialog, { + open: asLegacyGlobalFunctionForExtensionApi(openConfirmDialogInjectable), + confirm: asLegacyGlobalFunctionForExtensionApi(confirmInjectable), +}); + +export * from "../../renderer/components/icon"; +export * from "../../renderer/components/tooltip"; +export * from "../../renderer/components/tabs"; +export * from "../../renderer/components/table"; +export * from "../../renderer/components/badge"; +export * from "../../renderer/components/drawer"; +export * from "../../renderer/components/dialog"; +export * from "../../renderer/components/line-progress"; +export * from "../../renderer/components/menu"; + +export type { + CreateNotificationOptions, + Notification, + NotificationId, + NotificationMessage, + NotificationStatus, + ShowNotification, + NotificationsStore, +} from "../../renderer/components/notifications"; + +export const Notifications = { + ok: asLegacyGlobalFunctionForExtensionApi(showSuccessNotificationInjectable), + error: asLegacyGlobalFunctionForExtensionApi(showErrorNotificationInjectable), + checkedError: asLegacyGlobalFunctionForExtensionApi(showCheckedErrorNotificationInjectable), + info: asLegacyGlobalFunctionForExtensionApi(showInfoNotificationInjectable), + shortInfo: asLegacyGlobalFunctionForExtensionApi(showShortInfoNotificationInjectable), +}; + +export * from "../../renderer/components/spinner"; +export * from "../../renderer/components/stepper"; +export * from "../../renderer/components/wizard"; +export * from "../../renderer/components/+workloads-pods/pod-details-list"; +export * from "../../renderer/components/+namespaces/namespace-select"; +export * from "../../renderer/components/+namespaces/namespace-select-filter"; +export * from "../../renderer/components/layout/sub-title"; +export * from "../../renderer/components/input/search-input"; +export * from "../../renderer/components/chart/bar-chart"; +export * from "../../renderer/components/chart/pie-chart"; +export { + MonacoEditor, + type MonacoEditorProps, type MonacoEditorId, + type MonacoTheme, type MonacoCustomTheme, +} from "../../renderer/components/monaco-editor"; + +/** + * @deprecated Use `Renderer.Navigation.getDetailsUrl` + */ +export const getDetailsUrl = asLegacyGlobalFunctionForExtensionApi(getDetailsUrlInjectable); + +/** + * @deprecated Use `Renderer.Navigation.showDetails` + */ +export const showDetails = asLegacyGlobalFunctionForExtensionApi(showDetailsInjectable); + +// kube helpers +export * from "../../renderer/components/kube-object-details"; +export * from "../../renderer/components/kube-object-list-layout"; +export * from "../../renderer/components/kube-object-menu"; +export * from "../../renderer/components/kube-object-meta"; +export * from "../../renderer/components/+events/kube-event-details"; + +// specific exports +export * from "../../renderer/components/status-brick"; + +export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(createTerminalTabInjectable); + +export const terminalStore = asLegacyGlobalObjectForExtensionApiWithModifications( + terminalStoreInjectable, + { + sendCommand: asLegacyGlobalFunctionForExtensionApi(sendCommandInjectable), + }, +); + +const renameTab = asLegacyGlobalFunctionForExtensionApi(renameTabInjectable); +const podStore = asLegacyGlobalForExtensionApi(podStoreInjectable); + +export const logTabStore = asLegacyGlobalObjectForExtensionApiWithModifications( + logTabStoreInjectable, + { + createPodTab: asLegacyGlobalFunctionForExtensionApi(createPodLogsTabInjectable), + createWorkloadTab: asLegacyGlobalFunctionForExtensionApi(createWorkloadLogsTabInjectable), + renameTab: (tabId: string): void => { + const { selectedPodId } = logTabStore.getData(tabId) ?? {}; + const pod = selectedPodId && podStore.getById(selectedPodId); + + if (pod) { + renameTab(tabId, `Pod ${pod.getName()}`); + } + }, + tabs: undefined, + }, +); + +export class TerminalStore { + static getInstance() { + return terminalStore; + } + + static createInstance() { + return terminalStore; + } + + static resetInstance() { + console.warn("TerminalStore.resetInstance() does nothing"); + } +} + +export const notificationsStore = asLegacyGlobalForExtensionApi(notificationsStoreInjectable); diff --git a/packages/core/src/extensions/renderer-api/index.ts b/packages/core/src/extensions/renderer-api/index.ts new file mode 100644 index 0000000000..c58e278a75 --- /dev/null +++ b/packages/core/src/extensions/renderer-api/index.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Lens-extensions apis, required in renderer process runtime + +// APIs +import * as Catalog from "./catalog"; +import * as Component from "./components"; +import * as K8sApi from "./k8s-api"; +import * as Navigation from "./navigation"; +import * as Theme from "./theming"; +import { IpcRenderer as Ipc } from "../ipc/ipc-renderer"; +import { LensRendererExtension as LensExtension } from "../lens-renderer-extension"; + +export { + Catalog, + Component, + K8sApi, + Navigation, + Theme, + Ipc, + LensExtension, +}; diff --git a/packages/core/src/extensions/renderer-api/k8s-api.ts b/packages/core/src/extensions/renderer-api/k8s-api.ts new file mode 100644 index 0000000000..42b165602b --- /dev/null +++ b/packages/core/src/extensions/renderer-api/k8s-api.ts @@ -0,0 +1,129 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeResource } from "../../common/rbac"; +import { apiResourceRecord } from "../../common/rbac"; +import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import clusterRoleBindingApiInjectable from "../../common/k8s-api/endpoints/cluster-role-binding.api.injectable"; +import clusterRoleApiInjectable from "../../common/k8s-api/endpoints/cluster-role.api.injectable"; +import serviceAccountApiInjectable from "../../common/k8s-api/endpoints/service-account.api.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import roleApiInjectable from "../../common/k8s-api/endpoints/role.api.injectable"; +import podApiInjectable from "../../common/k8s-api/endpoints/pod.api.injectable"; +import daemonSetApiInjectable from "../../common/k8s-api/endpoints/daemon-set.api.injectable"; +import replicaSetApiInjectable from "../../common/k8s-api/endpoints/replica-set.api.injectable"; +import statefulSetApiInjectable from "../../common/k8s-api/endpoints/stateful-set.api.injectable"; +import deploymentApiInjectable from "../../common/k8s-api/endpoints/deployment.api.injectable"; +import jobApiInjectable from "../../common/k8s-api/endpoints/job.api.injectable"; +import cronJobApiInjectable from "../../common/k8s-api/endpoints/cron-job.api.injectable"; +import nodeApiInjectable from "../../common/k8s-api/endpoints/node.api.injectable"; +import configMapApiInjectable from "../../common/k8s-api/endpoints/config-map.api.injectable"; +import secretApiInjectable from "../../common/k8s-api/endpoints/secret.api.injectable"; +import resourceQuotaApiInjectable from "../../common/k8s-api/endpoints/resource-quota.api.injectable"; +import limitRangeApiInjectable from "../../common/k8s-api/endpoints/limit-range.api.injectable"; +import horizontalPodAutoscalerApiInjectable from "../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable"; +import podDisruptionBudgetApiInjectable from "../../common/k8s-api/endpoints/pod-disruption-budget.api.injectable"; +import priorityClassStoreApiInjectable from "../../common/k8s-api/endpoints/priority-class.api.injectable"; +import serviceApiInjectable from "../../common/k8s-api/endpoints/service.api.injectable"; +import endpointsApiInjectable from "../../common/k8s-api/endpoints/endpoint.api.injectable"; +import ingressApiInjectable from "../../common/k8s-api/endpoints/ingress.api.injectable"; +import networkPolicyApiInjectable from "../../common/k8s-api/endpoints/network-policy.api.injectable"; +import persistentVolumeApiInjectable from "../../common/k8s-api/endpoints/persistent-volume.api.injectable"; +import persistentVolumeClaimApiInjectable from "../../common/k8s-api/endpoints/persistent-volume-claim.api.injectable"; +import storageClassApiInjectable from "../../common/k8s-api/endpoints/storage-class.api.injectable"; +import namespaceApiInjectable from "../../common/k8s-api/endpoints/namespace.api.injectable"; +import kubeEventApiInjectable from "../../common/k8s-api/endpoints/events.api.injectable"; +import roleBindingApiInjectable from "../../common/k8s-api/endpoints/role-binding.api.injectable"; +import customResourceDefinitionApiInjectable from "../../common/k8s-api/endpoints/custom-resource-definition.api.injectable"; +import { shouldShowResourceInjectionToken } from "../../common/cluster-store/allowed-resources-injection-token"; + +export function isAllowedResource(resources: KubeResource | KubeResource[]) { + const di = getLegacyGlobalDiForExtensionApi(); + + return [resources].flat().every((resourceName) => { + const resource = apiResourceRecord[resourceName]; + + if (!resource) { + return true; + } + + const _isAllowedResource = di.inject(shouldShowResourceInjectionToken, { + apiName: resourceName, + group: resource.group, + }); + + // Note: Legacy isAllowedResource does not advertise reactivity + return _isAllowedResource.get(); + }); +} + +export const serviceAccountsApi = asLegacyGlobalForExtensionApi(serviceAccountApiInjectable); +export const clusterRoleApi = asLegacyGlobalForExtensionApi(clusterRoleApiInjectable); +export const clusterRoleBindingApi = asLegacyGlobalForExtensionApi(clusterRoleBindingApiInjectable); +export const roleApi = asLegacyGlobalForExtensionApi(roleApiInjectable); +export const podsApi = asLegacyGlobalForExtensionApi(podApiInjectable); +export const daemonSetApi = asLegacyGlobalForExtensionApi(daemonSetApiInjectable); +export const replicaSetApi = asLegacyGlobalForExtensionApi(replicaSetApiInjectable); +export const statefulSetApi = asLegacyGlobalForExtensionApi(statefulSetApiInjectable); +export const deploymentApi = asLegacyGlobalForExtensionApi(deploymentApiInjectable); +export const jobApi = asLegacyGlobalForExtensionApi(jobApiInjectable); +export const cronJobApi = asLegacyGlobalForExtensionApi(cronJobApiInjectable); +export const nodesApi = asLegacyGlobalForExtensionApi(nodeApiInjectable); +export const secretsApi = asLegacyGlobalForExtensionApi(secretApiInjectable); +export const configMapApi = asLegacyGlobalForExtensionApi(configMapApiInjectable); +export const resourceQuotaApi = asLegacyGlobalForExtensionApi(resourceQuotaApiInjectable); +export const limitRangeApi = asLegacyGlobalForExtensionApi(limitRangeApiInjectable); +export const serviceApi = asLegacyGlobalForExtensionApi(serviceApiInjectable); +export const hpaApi = asLegacyGlobalForExtensionApi(horizontalPodAutoscalerApiInjectable); +export const pdbApi = asLegacyGlobalForExtensionApi(podDisruptionBudgetApiInjectable); +export const pcApi = asLegacyGlobalForExtensionApi(priorityClassStoreApiInjectable); +export const endpointApi = asLegacyGlobalForExtensionApi(endpointsApiInjectable); +export const ingressApi = asLegacyGlobalForExtensionApi(ingressApiInjectable); +export const networkPolicyApi = asLegacyGlobalForExtensionApi(networkPolicyApiInjectable); +export const persistentVolumeApi = asLegacyGlobalForExtensionApi(persistentVolumeApiInjectable); +export const pvcApi = asLegacyGlobalForExtensionApi(persistentVolumeClaimApiInjectable); +export const storageClassApi = asLegacyGlobalForExtensionApi(storageClassApiInjectable); +export const namespacesApi = asLegacyGlobalForExtensionApi(namespaceApiInjectable); +export const eventApi = asLegacyGlobalForExtensionApi(kubeEventApiInjectable); +export const roleBindingApi = asLegacyGlobalForExtensionApi(roleBindingApiInjectable); +export const crdApi = asLegacyGlobalForExtensionApi(customResourceDefinitionApiInjectable); + +export * from "../common-api/k8s-api"; + +export { + KubeObjectStatusLevel, + type KubeObjectStatus, +} from "../../common/k8s-api/kube-object-status"; + +// stores +export type { EventStore } from "../../renderer/components/+events/store"; +export type { PodStore as PodsStore } from "../../renderer/components/+workloads-pods/store"; +export type { NodeStore as NodesStore } from "../../renderer/components/+nodes/store"; +export type { DeploymentStore } from "../../renderer/components/+workloads-deployments/store"; +export type { DaemonSetStore } from "../../renderer/components/+workloads-daemonsets/store"; +export type { StatefulSetStore } from "../../renderer/components/+workloads-statefulsets/store"; +export type { JobStore } from "../../renderer/components/+workloads-jobs/store"; +export type { CronJobStore } from "../../renderer/components/+workloads-cronjobs/store"; +export type { ConfigMapStore as ConfigMapsStore } from "../../renderer/components/+config-maps/store"; +export type { SecretStore as SecretsStore } from "../../renderer/components/+config-secrets/store"; +export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/store"; +export type { ResourceQuotaStore as ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/store"; +export type { LimitRangeStore as LimitRangesStore } from "../../renderer/components/+config-limit-ranges/store"; +export type { HorizontalPodAutoscalerStore as HPAStore } from "../../renderer/components/+config-autoscalers/store"; +export type { PodDisruptionBudgetStore as PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/store"; +export type { PriorityClassStore as PriorityClassStoreStore } from "../../renderer/components/+config-priority-classes/store"; +export type { ServiceStore } from "../../renderer/components/+network-services/store"; +export type { EndpointsStore as EndpointStore } from "../../renderer/components/+network-endpoints/store"; +export type { IngressStore } from "../../renderer/components/+network-ingresses/ingress-store"; +export type { IngressClassStore } from "../../renderer/components/+network-ingresses/ingress-class-store"; +export type { NetworkPolicyStore } from "../../renderer/components/+network-policies/store"; +export type { PersistentVolumeStore as PersistentVolumesStore } from "../../renderer/components/+storage-volumes/store"; +export type { PersistentVolumeClaimStore as VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/store"; +export type { StorageClassStore } from "../../renderer/components/+storage-classes/store"; +export type { NamespaceStore } from "../../renderer/components/+namespaces/store"; +export type { ServiceAccountStore as ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store"; +export type { RoleStore as RolesStore } from "../../renderer/components/+user-management/+roles/store"; +export type { RoleBindingStore as RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store"; +export type { CustomResourceDefinitionStore as CRDStore } from "../../renderer/components/+custom-resources/definition.store"; +export type { CustomResourceStore as CRDResourceStore } from "../../common/k8s-api/api-manager/resource.store"; diff --git a/packages/core/src/extensions/renderer-api/navigation.ts b/packages/core/src/extensions/renderer-api/navigation.ts new file mode 100644 index 0000000000..ec7081c948 --- /dev/null +++ b/packages/core/src/extensions/renderer-api/navigation.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import getDetailsUrlInjectable from "../../renderer/components/kube-detail-params/get-details-url.injectable"; +import hideDetailsInjectable from "../../renderer/components/kube-detail-params/hide-details.injectable"; +import showDetailsInjectable from "../../renderer/components/kube-detail-params/show-details.injectable"; +import createPageParamInjectable from "../../renderer/navigation/create-page-param.injectable"; +import isActiveRouteInjectable from "../../renderer/navigation/is-route-active.injectable"; +import navigateInjectable from "../../renderer/navigation/navigate.injectable"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; + +export type { PageParamInit, PageParam } from "../../renderer/navigation/page-param"; +export type { URLParams } from "../../common/utils/buildUrl"; + +export const getDetailsUrl = asLegacyGlobalFunctionForExtensionApi(getDetailsUrlInjectable); +export const showDetails = asLegacyGlobalFunctionForExtensionApi(showDetailsInjectable); +export const hideDetails = asLegacyGlobalFunctionForExtensionApi(hideDetailsInjectable); +export const createPageParam = asLegacyGlobalFunctionForExtensionApi(createPageParamInjectable); +export const isActiveRoute = asLegacyGlobalFunctionForExtensionApi(isActiveRouteInjectable); +export const navigate = asLegacyGlobalFunctionForExtensionApi(navigateInjectable); diff --git a/packages/core/src/extensions/renderer-api/theming.ts b/packages/core/src/extensions/renderer-api/theming.ts new file mode 100644 index 0000000000..3e4c9fe97b --- /dev/null +++ b/packages/core/src/extensions/renderer-api/theming.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import activeThemeInjectable from "../../renderer/themes/active.injectable"; +import type { LensTheme } from "../../renderer/themes/lens-theme"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +export const activeTheme = asLegacyGlobalForExtensionApi(activeThemeInjectable); + +/** + * @deprecated This hides the reactivity of active theme, use {@link activeTheme} instead + */ +export function getActiveTheme() { + return activeTheme.get(); +} + +export type { LensTheme }; diff --git a/packages/core/src/extensions/renderer-extensions.injectable.ts b/packages/core/src/extensions/renderer-extensions.injectable.ts new file mode 100644 index 0000000000..92c3037e01 --- /dev/null +++ b/packages/core/src/extensions/renderer-extensions.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import extensionsInjectable from "./extensions.injectable"; +import type { LensRendererExtension } from "./lens-renderer-extension"; + +const rendererExtensionsInjectable = getInjectable({ + id: "renderer-extensions", + instantiate: (di) => di.inject(extensionsInjectable) as IComputedValue, +}); + +export default rendererExtensionsInjectable; diff --git a/packages/core/src/features/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap b/packages/core/src/features/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap new file mode 100644 index 0000000000..ccf3a4153f --- /dev/null +++ b/packages/core/src/features/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap @@ -0,0 +1,488 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extension special characters in page registrations renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+ +
+
+

+ Welcome to some-product-name! +

+

+ To get you started we have auto-detected your clusters in your + + kubeconfig file and added them to the catalog, your centralized + + view for managing all your cloud-native resources. +
+
+ If you have any questions or feedback, please join our + + Lens Community slack channel + + . +

+ +
+
+
+
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; + +exports[`extension special characters in page registrations when navigating to route with ID having special characters renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+ Some page +
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; diff --git a/packages/core/src/features/__snapshots__/navigate-to-extension-page.test.tsx.snap b/packages/core/src/features/__snapshots__/navigate-to-extension-page.test.tsx.snap new file mode 100644 index 0000000000..b66ac6c4b3 --- /dev/null +++ b/packages/core/src/features/__snapshots__/navigate-to-extension-page.test.tsx.snap @@ -0,0 +1,1148 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`navigate to extension page renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+ +
+
+

+ Welcome to some-product-name! +

+

+ To get you started we have auto-detected your clusters in your + + kubeconfig file and added them to the catalog, your centralized + + view for managing all your cloud-native resources. +
+
+ If you have any questions or feedback, please join our + + Lens Community slack channel + + . +

+ +
+
+
+
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; + +exports[`navigate to extension page when extension navigates to child route renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+ Child page +
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; + +exports[`navigate to extension page when extension navigates to route with parameters renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
    +
  • + some-string-value-from-navigate +
  • +
  • + 126 +
  • +
  • + some-array-value-from-navigate +
  • +
+ +
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; + +exports[`navigate to extension page when extension navigates to route without parameters renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
    +
  • + some-string-value +
  • +
  • + 42 +
  • +
  • + some-array-value,some-other-array-value +
  • +
+ +
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; + +exports[`navigate to extension page when extension navigates to route without parameters when changing page parameters renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
    +
  • + some-changed-string-value +
  • +
  • + 84 +
  • +
  • + some-changed-array-value,some-other-changed-array-value +
  • +
+ +
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; diff --git a/packages/core/src/features/__snapshots__/navigating-between-routes.test.tsx.snap b/packages/core/src/features/__snapshots__/navigating-between-routes.test.tsx.snap new file mode 100644 index 0000000000..98b22c8ec3 --- /dev/null +++ b/packages/core/src/features/__snapshots__/navigating-between-routes.test.tsx.snap @@ -0,0 +1,412 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`navigating between routes given route with optional path parameters when navigating to route with path parameters renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+        {
+  "someParameter": "some-value",
+  "someOtherParameter": "some-other-value"
+}
+      
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; + +exports[`navigating between routes given route without path parameters when navigating to route renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+ Some component +
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; diff --git a/packages/core/src/features/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap b/packages/core/src/features/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap new file mode 100644 index 0000000000..28cdcc4b8d --- /dev/null +++ b/packages/core/src/features/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap @@ -0,0 +1,572 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`add-cluster - navigation using application menu renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+ +
+
+

+ Welcome to some-product-name! +

+

+ To get you started we have auto-detected your clusters in your + + kubeconfig file and added them to the catalog, your centralized + + view for managing all your cloud-native resources. +
+
+ If you have any questions or feedback, please join our + + Lens Community slack channel + + . +

+ +
+
+
+
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+`; + +exports[`add-cluster - navigation using application menu when navigating to add cluster using application menu renders 1`] = ` +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+
+

+ Add Clusters from Kubeconfig +

+

+ Clusters added here are + + not + + merged into the + + ~/.kube/config + + file. + + Read more about adding clusters. + +

+
+ +
+
+
+
+
+
+ +`; + +exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given clicking the context menu for second namespace, when clicking to edit namespace renders 1`] = ` + +
+
+
+
+
+ +
+ + + close + + +
+ Close +
+
+
+
+
+
+
+
+