diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index 91a386a27ce..0265125c6e5 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -51,20 +51,21 @@ jobs: - name: Try the cluster! run: kubectl get pods -A - name: Restore image-cache Folder - id: cache-image-restore + id: cache-image-restore2 uses: actions/cache/restore@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 with: path: ~/image-cache # cache the container image. All the paths this PR depends on except the e2e-tests folder for the key. key: ${{ runner.os }}-image-${{ hashFiles('backend/pkg/**', 'backend/cmd/**', 'backend/go.*', 'frontend/src/**', 'frontend/package.json', 'frontend/package-lock.json', 'Makefile', '.github/workflows/build-container.yml', 'Dockerfile', 'Dockerfile.plugins') }} - name: Restore Cached Docker Images - if: steps.cache-image-restore.outputs.cache-hit == 'true' + if: steps.cache-image-restore2.outputs.cache-hit == 'true' run: | export SHELL=/bin/bash - docker load -i ~/image-cache/headlamp-plugins-test.tar - docker load -i ~/image-cache/headlamp.tar + mkdir -p ~/image-cache + gzip -dc ~/image-cache/headlamp-plugins-test.tar.gz | docker load + gzip -dc ~/image-cache/headlamp.tar.gz | docker load - name: Make a .plugins folder for testing later - if: steps.cache-image-restore.outputs.cache-hit != 'true' + if: steps.cache-image-restore2.outputs.cache-hit != 'true' run: | echo "Extract pod-counter plugin into .plugins folder, which will be copied into image later by 'make image'." cd plugins/examples/pod-counter @@ -77,12 +78,12 @@ jobs: cd ../../ ls -laR .plugins - name: Remove unnecessary files - if: steps.cache-image-restore.outputs.cache-hit != 'true' + if: steps.cache-image-restore2.outputs.cache-hit != 'true' run: | sudo rm -rf /usr/share/dotnet sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Build image - if: steps.cache-image-restore.outputs.cache-hit != 'true' + if: steps.cache-image-restore2.outputs.cache-hit != 'true' run: | export SHELL=/bin/bash DOCKER_IMAGE_VERSION=latest make image @@ -95,7 +96,7 @@ jobs: kind load docker-image ghcr.io/headlamp-k8s/headlamp-plugins-test:latest --name test kind load docker-image ghcr.io/headlamp-k8s/headlamp:latest --name test - name: Test .plugins folder - if: steps.cache-image-restore.outputs.cache-hit != 'true' + if: steps.cache-image-restore2.outputs.cache-hit != 'true' run: | export SHELL=/bin/bash echo "----------------------------" @@ -186,20 +187,30 @@ jobs: else echo "Playwright tests passed successfully" fi + # Clear disk space by removing unnecessary files, apt files and uninstall some playwright dependencies + - name: Clear Disk Space + if: steps.cache-image-restore2.outputs.cache-hit != 'true' + run: | + export SHELL=/bin/bash + sudo rm -rf /var/lib/apt/lists/* + sudo apt-get remove -y libgbm-dev libxcb-dri3-0 libxcb-dri2-0 libxcb-xfixes0 libxcb-shape0 libxcb-shm0 libxcb-render0 libxcb-glx0 || true + sudo rm -rf e2e-tests/node_modules/ + kind delete cluster --name test + kind delete cluster --name test2 - name: Save Docker Images to Tar files in image-cache Folder - if: steps.cache-image-restore.outputs.cache-hit != 'true' + if: steps.cache-image-restore2.outputs.cache-hit != 'true' run: | export SHELL=/bin/bash mkdir -p ~/image-cache - docker save -o ~/image-cache/headlamp-plugins-test.tar ghcr.io/headlamp-k8s/headlamp-plugins-test - docker save -o ~/image-cache/headlamp.tar ghcr.io/headlamp-k8s/headlamp + docker save ghcr.io/headlamp-k8s/headlamp-plugins-test | gzip -9 > ~/image-cache/headlamp-plugins-test.tar.gz + docker save ghcr.io/headlamp-k8s/headlamp | gzip -9 > ~/image-cache/headlamp.tar.gz - name: Cache image-cache Folder - if: steps.cache-image-restore.outputs.cache-hit != 'true' + if: steps.cache-image-restore2.outputs.cache-hit != 'true' id: cache-image-save uses: actions/cache/save@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.1 with: path: ~/image-cache - key: ${{ steps.cache-image-restore.outputs.cache-primary-key }} + key: ${{ steps.cache-image-restore2.outputs.cache-primary-key }} - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 if: always() with: diff --git a/.github/workflows/pr-to-update-homebrew.yml b/.github/workflows/pr-to-update-homebrew.yml deleted file mode 100644 index 7c0c5816f6d..00000000000 --- a/.github/workflows/pr-to-update-homebrew.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: PR to update homebrew - -# This action will run after a tag starting with "v" is published -on: - push: - tags: - - 'v*' - workflow_dispatch: - -env: - LATEST_HEADLAMP_TAG: latest -permissions: - contents: read - -jobs: - create_pr_to_upgrade_homebrew: - name: Create PR to upgrade homebrew - runs-on: ubuntu-latest - permissions: - contents: write # needed to push a branch - pull-requests: write # needed to open a pull request - - steps: - - name: Checkout headlamp repo - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - path: headlamp - fetch-depth: 0 - - name: Configure Git - run: | - user=${{github.actor}} - if [ -z $user ]; then - user=yolossn - fi - git config --global user.name "$user" - git config --global user.email "$user@users.noreply.github.com" - - name: Get headlamp latest tag - run: | - cd headlamp - latestTag=$(git tag --list --sort=version:refname 'v*' | tail -1) - echo "LATEST_HEADLAMP_TAG=$latestTag" >> $GITHUB_ENV - echo $latestTag - - name: Sync homebrew-cask fork from upstream - run: | - gh repo sync headlamp-k8s/homebrew-cask - env: - GITHUB_TOKEN: ${{ secrets.KINVOLK_REPOS_TOKEN }} - - name: Check out homebrew-cask repo - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - repository: headlamp-k8s/homebrew-cask - path: homebrew-cask - token: ${{ secrets.KINVOLK_REPOS_TOKEN }} - fetch-depth: 0 - - name: Update headlamp version in homebrew-cask - run: | - user=${{github.actor}} - HEADLAMP_VERSION=${LATEST_HEADLAMP_TAG:1} - BRANCH_NAME="hl-ci-update_headlamp_$HEADLAMP_VERSION" - if [ -z $user ]; then - user=yolossn - fi - cd homebrew-cask - if git branch -l | grep -q "$BRANCH_NAME"; then - echo "deleting old branch from local to avoid conflict" - git branch -D "$BRANCH_NAME" - fi - if git branch -a | grep -q "origin/$BRANCH_NAME"; then - echo "deleting old branch from remote to avoid conflict" - git push origin --delete "$BRANCH_NAME" - fi - wget "https://github.com/kubernetes-sigs/headlamp/releases/download/$LATEST_HEADLAMP_TAG/checksums.txt" - ARM_SHA=$(cat checksums.txt | grep .arm64.dmg | awk -F" " '{print $1}') - INTEL_SHA=$(cat checksums.txt | grep .x64.dmg | awk -F" " '{print $1}') - git checkout -b "$BRANCH_NAME" - sed -i "s/version\ .*/version \"$HEADLAMP_VERSION\"/g" ./Casks/h/headlamp.rb - if [ $ARM_SHA ]; then - echo "replacing ARM SHA" - sed -i "s/sha256 arm:\ \".*/sha256 arm:\ \"$ARM_SHA\",/g" ./Casks/h/headlamp.rb - fi - if [ $INTEL_SHA ]; then - echo "replacing Intel SHA" - sed -i "s/ intel:\ \".*/ intel:\ \"$INTEL_SHA\"/g" ./Casks/h/headlamp.rb - fi - git diff - rm ./checksums.txt - git add ./Casks/h/headlamp.rb - git status - git commit --signoff -m "Update Headlamp version to $HEADLAMP_VERSION" - git status - git log -1 - git push origin "$BRANCH_NAME" -f - gh pr create \ - --title "Upgrade Headlamp version to $HEADLAMP_VERSION" \ - --repo "Homebrew/homebrew-cask" \ - --head "headlamp-k8s:$BRANCH_NAME" \ - --base "master" \ - --assignee "$user" \ - --body "Upgrade Headlamp version to $HEADLAMP_VERSION - cc: @$user" \ - env: - LATEST_HEADLAMP_TAG: ${{ env.LATEST_HEADLAMP_TAG }} - GITHUB_TOKEN: ${{ secrets.KINVOLK_REPOS_TOKEN }} diff --git a/.github/workflows/pr-to-update-winget.yml b/.github/workflows/pr-to-update-winget.yml deleted file mode 100644 index 3eac5891e49..00000000000 --- a/.github/workflows/pr-to-update-winget.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: PR for updating winget - -# This action will run after a tag starting with "v" is published -on: - push: - tags: - - "v*" - workflow_dispatch: - -permissions: - contents: read - -jobs: - winget-update: - permissions: - contents: write # for Git to git push - pull-requests: write # for creating PRs - runs-on: ubuntu-latest - steps: - - name: Checkout Headlamp - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - token: ${{ secrets.KINVOLK_REPOS_TOKEN }} - # we need the full history for the git tag command, so fetch all the branches - fetch-depth: 0 - - - name: Configure Git - run: | - user=${{github.actor}} - if [ -z $user ]; then - user=vyncent-t - fi - git config --global user.name "$user" - git config --global user.email "$user@users.noreply.github.com" - - # Set up Node.js environment, pay attention to the version - # Some features might not be available in older versions - - name: Create node.js environment - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 - with: - node-version: "21" - - # Install the dependencies for the winget script - - name: Install winget dependencies - run: | - cd $GITHUB_WORKSPACE/app - npm ci - - # We set the latest tag as an environment variable before we use it in the next steps - # note that we have to echo the variable to the environment file to make it available in the next steps - - name: Set latest tag - run: | - echo "Setting latest tag" - latestTag=$(git tag --list --sort=version:refname 'v*' | tail -1) - # Remove the 'v' from the tag - latestTag=${latestTag#v} - echo "LATEST_HEADLAMP_TAG=$latestTag" >> $GITHUB_ENV - echo $latestTag - - # checkout the winget-pkgs repository - - name: Checkout winget-pkgs - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - repository: headlamp-k8s/winget-pkgs - path: winget-pkgs - token: ${{ secrets.KINVOLK_REPOS_TOKEN }} - # we need the full history for the git tag command, so fetch all the branches - fetch-depth: 0 - - # Run the winget script - - name: Create winget package - run: | - echo "Running winget script" - echo "Repository: ${{ github.repository }}" - echo "Workspace: ${GITHUB_WORKSPACE}" - echo $GITHUB_WORKSPACE - pwd - echo "creating winget pkgs for ${LATEST_HEADLAMP_TAG}" - cd $GITHUB_WORKSPACE/app/windows/winget - node winget-create.js $LATEST_HEADLAMP_TAG $GITHUB_WORKSPACE/winget-pkgs/manifests/h/Headlamp/Headlamp - echo "Script finished" - - - name: Create PR branch - run: | - user=${{github.actor}} - if [ -z $user ]; then - user=vyncent-t - fi - echo "Creating PR branch" - echo "Repository: ${{ github.repository }}" - echo "Workspace: ${GITHUB_WORKSPACE}" - pwd - echo "moving to winget-pkgs directory" - cd $GITHUB_WORKSPACE/winget-pkgs - pwd - ls - echo "moving to Headlamp directory" - cd $GITHUB_WORKSPACE/winget-pkgs/manifests/h/Headlamp/Headlamp - pwd - ls - git checkout -b "hl-ci-winget-update-$LATEST_HEADLAMP_TAG" - git add . - git commit -s -m "Update winget package $LATEST_HEADLAMP_TAG" - git push origin "hl-ci-winget-update-$LATEST_HEADLAMP_TAG" - env: - GITHUB_TOKEN: ${{ secrets.KINVOLK_REPOS_TOKEN }} - - - name: Create Pull Request - run: | - echo "Create pull request" - echo "continue with the following link" - echo "https://github.com/headlamp-k8s/winget-pkgs/pull/new/hl-ci-winget-update-$LATEST_HEADLAMP_TAG" diff --git a/.github/workflows/push-chocolatey-pkg.yml b/.github/workflows/push-chocolatey-pkg.yml new file mode 100644 index 00000000000..306c06b96ce --- /dev/null +++ b/.github/workflows/push-chocolatey-pkg.yml @@ -0,0 +1,50 @@ +name: Push Chocolatey Package + +# This action will run when a PR that updates the Chocolatey package is merged to main +# It can also be triggered manually +on: + push: + branches: + - main + paths: + - 'app/windows/chocolatey/headlamp.nuspec' + - 'app/windows/chocolatey/tools/chocolateyinstall.ps1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + push-chocolatey: + runs-on: windows-latest + steps: + - name: Checkout Headlamp + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + ref: main + + - name: Install Chocolatey + run: | + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + shell: powershell + + - name: Pack Chocolatey Package + run: | + cd app/windows/chocolatey + choco pack + shell: powershell + + - name: Push to Chocolatey + run: | + cd app/windows/chocolatey + $nupkg = Get-ChildItem -Filter *.nupkg | Select-Object -First 1 + if ($nupkg) { + Write-Host "Pushing package: $($nupkg.Name)" + choco push $nupkg.FullName --source https://push.chocolatey.org/ --key ${{ secrets.CHOCOLATEY_API_KEY }} + } else { + Write-Error "No .nupkg file found" + exit 1 + } + shell: powershell diff --git a/app/app-build-manifest.json b/app/app-build-manifest.json index bbb87ce0e42..5d80194b27e 100644 --- a/app/app-build-manifest.json +++ b/app/app-build-manifest.json @@ -3,11 +3,11 @@ "plugins": [ { "name": "app-catalog", - "archive": "https://github.com/headlamp-k8s/plugins/releases/download/app-catalog-0.6.3/app-catalog-0.6.3.tar.gz" + "archive": "https://github.com/headlamp-k8s/plugins/releases/download/app-catalog-0.7.0/app-catalog-0.7.0.tar.gz" }, { "name": "plugin-catalog", - "archive": "https://github.com/headlamp-k8s/plugins/releases/download/plugin-catalog-0.4.1/headlamp-k8s-plugin-catalog-0.4.1.tar.gz" + "archive": "https://github.com/headlamp-k8s/plugins/releases/download/plugin-catalog-0.4.2/headlamp-k8s-plugin-catalog-0.4.2.tar.gz" }, { "name": "prometheus", diff --git a/app/e2e-tests/README.md b/app/e2e-tests/README.md index 1fc1da0132c..7cab527a3eb 100644 --- a/app/e2e-tests/README.md +++ b/app/e2e-tests/README.md @@ -41,17 +41,17 @@ To run the tests for the web mode, you will need to have the backend running. Fo `cd headlamp` - run the following command - `make backend` followed by `make run-backend` + `npm run backend:build` followed by `npm run backend:start` ### Frontend To run the tests for the web mode, you will need to have the frontend running. Follow the steps below to run the frontend: - cd into the headlamp directory in a separate terminal - `cd headlamp/frontend` + `cd headlamp` - run the following command - `make frontend` followed by `make run-frontend` + `npm run frontend:build` followed by `npm run frontend:start` ### Running the tests diff --git a/app/e2e-tests/package-lock.json b/app/e2e-tests/package-lock.json index 8ee2f7b8a9c..2733c2ad3bf 100644 --- a/app/e2e-tests/package-lock.json +++ b/app/e2e-tests/package-lock.json @@ -14,13 +14,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz", - "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.48.1" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -55,13 +55,13 @@ } }, "node_modules/playwright": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz", - "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.48.1" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -74,9 +74,9 @@ } }, "node_modules/playwright-core": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz", - "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/app/e2e-tests/tests/clusterRename.spec.ts b/app/e2e-tests/tests/clusterRename.spec.ts new file mode 100644 index 00000000000..f4f442d9206 --- /dev/null +++ b/app/e2e-tests/tests/clusterRename.spec.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test } from '@playwright/test'; +import path from 'path'; +import { _electron, Page } from 'playwright'; +import { HeadlampPage } from './headlampPage'; + +// Electron setup +const electronExecutable = process.platform === 'win32' ? 'electron.cmd' : 'electron'; +const electronPath = path.resolve(__dirname, `../../node_modules/.bin/${electronExecutable}`); +const appPath = path.resolve(__dirname, '../../'); +let electronApp; +let electronPage: Page; + +// Test configuration +const TEST_CONFIG = { + originalName: 'minikube', + newName: 'test-cluster', + cancelledName: 'cancelled-cluster', + invalidName: 'Invalid Cluster!', +}; + +// Helper functions +async function navigateToSettings(page: Page) { + await page.waitForLoadState('load'); + await page.getByRole('button', { name: 'Settings' }).click(); + await page.waitForLoadState('load'); +} + +async function verifyClusterName(page: Page, expectedName: string) { + await page.getByRole('button', { name: 'Settings' }).click(); + await page.locator('a[href="#/settings/cluster"]').click(); + // Check the cluster name in the cluster selector combobox + await expect(page.locator(`input[placeholder="${expectedName}"]`)).toBeVisible(); +} + +async function renameCluster( + page: Page, + fromName: string, + toName: string, + confirm: boolean = true +) { + await page.fill(`input[placeholder="${fromName}"]`, toName); + await page.getByRole('button', { name: 'Apply' }).click(); + await page.getByRole('button', { name: confirm ? 'Yes' : 'No' }).click(); + await page.waitForLoadState('load'); + await page.locator(`a[href="#/c/${toName}/"]`).click(); +} + +// Setup +test.beforeAll(async () => { + electronApp = await _electron.launch({ + cwd: appPath, + executablePath: electronPath, + args: ['.'], + env: { + ...process.env, + NODE_ENV: 'development', + ELECTRON_DEV: 'true', + }, + }); + + electronPage = await electronApp.firstWindow(); +}); + +test.beforeEach(async ({ page }) => { + page.close(); +}); + +// Tests +test.describe('Cluster rename functionality', () => { + test.beforeEach(() => { + test.skip(process.env.PLAYWRIGHT_TEST_MODE !== 'app', 'These tests only run in app mode'); + }); + + test('should rename cluster and verify changes', async ({ page: browserPage }) => { + const page = process.env.PLAYWRIGHT_TEST_MODE === 'app' ? electronPage : browserPage; + const headlampPage = new HeadlampPage(page); + await headlampPage.authenticate(); + + await navigateToSettings(page); + await expect(page.locator('h2')).toContainText('Cluster Settings'); + + // Test invalid inputs + await page.fill('input[placeholder="minikube"]', TEST_CONFIG.invalidName); + await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled(); + + await page.fill('input[placeholder="minikube"]', ''); + await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled(); + + // Test successful rename + await renameCluster(page, TEST_CONFIG.originalName, TEST_CONFIG.newName); + await verifyClusterName(page, TEST_CONFIG.newName); + }); +}); diff --git a/app/electron/main.ts b/app/electron/main.ts index 68beaba0e01..97675f555fa 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -40,6 +40,7 @@ import url from 'url'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import i18n from './i18next.config'; +import ElectronMCPClient from './mcp-client'; import { addToPath, ArtifactHubHeadlampPkg, @@ -131,6 +132,7 @@ const shouldCheckForUpdates = process.env.HEADLAMP_CHECK_FOR_UPDATES !== 'false' // make it global so that it doesn't get garbage collected let mainWindow: BrowserWindow | null; +let mcpClient: ElectronMCPClient; /** * `Action` is an interface for an action to be performed by the plugin manager. @@ -957,8 +959,7 @@ function getDefaultAppMenu(): AppMenu[] { }, { label: i18n.t('About'), - id: 'original-about', - url: 'https://github.com/kubernetes-sigs/headlamp', + id: 'original-about-help', }, ], }, @@ -1052,7 +1053,12 @@ function menusToTemplate(mainWindow: BrowserWindow | null, menusFromPlugins: App return; } - if (!!url) { + // Handle the "About" menu item from the Help menu specially + if (appMenu.id === 'original-about-help') { + menu.click = () => { + mainWindow?.webContents.send('open-about-dialog'); + }; + } else if (!!url) { menu.click = async () => { // Open external links in the external browser. if (!!mainWindow && !url.startsWith('http')) { @@ -1132,6 +1138,13 @@ function adjustZoom(delta: number) { function startElecron() { console.info('App starting...'); + mcpClient = new ElectronMCPClient(); + + // Initialize MCP client + mcpClient.initialize().catch(error => { + console.error('Failed to initialize MCP client on startup:', error); + }); + let appVersion: string; if (isDev && process.env.HEADLAMP_APP_VERSION) { appVersion = process.env.HEADLAMP_APP_VERSION; @@ -1247,6 +1260,9 @@ function startElecron() { }, }); + // Set the main window reference in the MCP client for dialogs + mcpClient.setMainWindow(mainWindow); + // Load the frontend mainWindow.loadURL(startUrl); @@ -1411,6 +1427,15 @@ function startElecron() { mainWindow?.webContents.send('backend-token', backendToken); }); + // Handle cluster change notifications from frontend + ipcMain.on('cluster-changed', (event: IpcMainEvent, cluster: string | null) => { + if (mcpClient) { + mcpClient.handleClusterChange(cluster).catch(error => { + console.error('Failed to handle cluster change in MCP client:', error); + }); + } + }); + setupRunCmdHandlers(mainWindow, ipcMain); new PluginManagerEventListeners().setupEventHandlers(); @@ -1463,6 +1488,11 @@ function startElecron() { if (mainWindow) { mainWindow.removeAllListeners('close'); } + + // Cleanup MCP client + if (mcpClient) { + mcpClient.cleanup().catch(console.error); + } }); } diff --git a/app/electron/mcp-client.ts b/app/electron/mcp-client.ts new file mode 100644 index 00000000000..1b16c3374ba --- /dev/null +++ b/app/electron/mcp-client.ts @@ -0,0 +1,1110 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MultiServerMCPClient } from '@langchain/mcp-adapters'; +import { app, BrowserWindow, dialog, ipcMain } from 'electron'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { MCPConfigManager } from './mcp-config'; + +/** + * Enable debug logging for MCP client operations. + * Controlled by the HEADLAMP_MCP_DEBUG environment variable. + * Set HEADLAMP_MCP_DEBUG=true to enable debug logging, or HEADLAMP_MCP_DEBUG=false to disable it. + * Defaults to false if not set. + */ +const DEBUG = process.env.HEADLAMP_MCP_DEBUG === 'true'; + +/** + * Helper function for debug logging + * @param args - Arguments to log + */ +function debugLog(...args: any[]): void { + if (DEBUG) { + console.log('[MCP]', ...args); + } +} + +/** + * Configuration for an MCP (Model Context Protocol) server + */ +interface MCPServer { + /** Unique name identifier for the MCP server */ + name: string; + /** Command to execute to start the MCP server */ + command: string; + /** Arguments to pass to the server command */ + args: string[]; + /** Whether this server is enabled */ + enabled: boolean; + /** Optional environment variables to set for the server process */ + env?: Record; +} + +/** + * Main MCP configuration containing server definitions + */ +interface MCPConfig { + /** Whether MCP integration is enabled globally */ + enabled: boolean; + /** List of configured MCP servers */ + servers: MCPServer[]; +} + +/** + * Manages MCP (Model Context Protocol) client for Electron desktop application. + * + * This class handles: + * - MCP server lifecycle (initialization, restart, cleanup) + * - Tool discovery and execution with user confirmation + * - Configuration management and validation + * - IPC communication with renderer process + * - Cluster context changes + */ +class ElectronMCPClient { + /** The LangChain MCP client instance managing multiple servers */ + private client: MultiServerMCPClient | null = null; + /** Cached list of available tools from all MCP servers */ + private tools: any[] = []; + /** Whether the MCP client has been successfully initialized */ + private isInitialized = false; + /** Promise tracking ongoing initialization to prevent duplicate initializations */ + private initializationPromise: Promise | null = null; + /** Manages tool-level configuration and statistics */ + private configManager: MCPConfigManager; + /** Reference to the main Electron window for displaying dialogs */ + private mainWindow: BrowserWindow | null = null; + /** Currently active Kubernetes cluster context */ + private currentCluster: string | null = null; + + /** + * Creates a new ElectronMCPClient instance + * @param mainWindow - Optional reference to the main browser window for dialogs + */ + constructor(mainWindow: BrowserWindow | null = null) { + this.mainWindow = mainWindow; + this.configManager = new MCPConfigManager(); + this.setupIpcHandlers(); + } + + /** + * Set the main window reference for dialogs + */ + setMainWindow(mainWindow: BrowserWindow | null): void { + this.mainWindow = mainWindow; + } + + /** + * Initialize tools configuration for all available tools + * This completely replaces the existing config with current tools + */ + private initializeToolsConfiguration(): void { + if (!this.tools || this.tools.length === 0) { + debugLog('No tools available for configuration initialization'); + // Clear the config if no tools are available + this.configManager.replaceConfig({}); + return; + } + + // Group tools by server name with their schemas + const toolsByServer: Record< + string, + Array<{ + name: string; + inputSchema?: any; + description?: string; + }> + > = {}; + + for (const tool of this.tools) { + // Extract server name from tool name (format: "serverName__toolName") + const toolName = tool.name; + const parts = toolName.split('__'); + + // Extract schema from the tool (LangChain tools use .schema property) + const toolSchema = (tool as any).schema || tool.inputSchema || null; + debugLog( + `Processing tool: ${toolName}, has inputSchema: ${!!toolSchema}, description: "${ + tool.description + }"` + ); + + if (parts.length >= 2) { + const serverName = parts[0]; + const actualToolName = parts.slice(1).join('__'); + + if (!toolsByServer[serverName]) { + toolsByServer[serverName] = []; + } + toolsByServer[serverName].push({ + name: actualToolName, + inputSchema: toolSchema, + description: tool.description || '', + }); + } else { + // Fallback for tools without server prefix + if (!toolsByServer['default']) { + toolsByServer['default'] = []; + } + toolsByServer['default'].push({ + name: toolName, + inputSchema: toolSchema, + description: tool.description || '', + }); + } + } + + debugLog('Tools grouped by server:', Object.keys(toolsByServer)); + + // Replace the entire configuration with current tools + this.configManager.replaceToolsConfig(toolsByServer); + } + + /** + * Show user confirmation dialog for MCP operations. + * Displays a dialog to the user for security confirmation before executing MCP operations. + * + * @param title - Dialog title + * @param message - Main message to display to the user + * @param operation - Description of the operation being performed + * @returns Promise resolving to true if user allows the operation, false otherwise + */ + private async showConfirmationDialog( + title: string, + message: string, + operation: string + ): Promise { + if (!this.mainWindow) { + console.warn('No main window available for confirmation dialog, allowing operation'); + return true; + } + + const result = await dialog.showMessageBox(this.mainWindow, { + type: 'question', + buttons: ['Allow', 'Cancel'], + defaultId: 1, + title, + message, + detail: `Operation: ${operation}\n\nDo you want to allow this MCP operation?`, + }); + + return result.response === 0; // 0 is "Allow" + } + + /** + * Show detailed confirmation dialog for tools configuration changes. + * Compares current and new configurations and displays a summary of changes. + * + * @param newConfig - The new configuration to be applied + * @returns Promise resolving to true if user approves changes, false otherwise + */ + private async showToolsConfigConfirmationDialog(newConfig: any): Promise { + if (!this.mainWindow) { + console.warn('No main window available for confirmation dialog, allowing operation'); + return true; + } + + const currentConfig = this.configManager.getConfig(); + const summary = this.createToolsConfigSummary(currentConfig, newConfig); + + if (summary.totalChanges === 0) { + return true; // No changes, allow operation + } + + const result = await dialog.showMessageBox(this.mainWindow, { + type: 'question', + buttons: ['Apply Changes', 'Cancel'], + defaultId: 1, + title: 'MCP Tools Configuration Changes', + message: `${summary.totalChanges} tool configuration change(s) will be applied:`, + detail: summary.summaryText + '\n\nDo you want to apply these changes?', + }); + + return result.response === 0; // 0 is "Apply Changes" + } + + /** + * Create a concise summary of tools configuration changes. + * Analyzes differences between current and new tool configurations. + * + * @param currentConfig - Current tool configuration + * @param newConfig - New tool configuration to compare against + * @returns Object containing total changes count and formatted summary text + */ + private createToolsConfigSummary( + currentConfig: any, + newConfig: any + ): { + totalChanges: number; + summaryText: string; + } { + const enabledTools: string[] = []; + const disabledTools: string[] = []; + const addedTools: string[] = []; + const removedTools: string[] = []; + + // Get all server names from both configs + const allServers = new Set([ + ...Object.keys(currentConfig || {}), + ...Object.keys(newConfig || {}), + ]); + + for (const serverName of allServers) { + const currentServerConfig = currentConfig[serverName] || {}; + const newServerConfig = newConfig[serverName] || {}; + + // Get all tool names from both configs + const allTools = new Set([ + ...Object.keys(currentServerConfig), + ...Object.keys(newServerConfig), + ]); + + for (const toolName of allTools) { + const currentTool = currentServerConfig[toolName]; + const newTool = newServerConfig[toolName]; + const displayName = `${toolName} (${serverName})`; + + if (!currentTool && newTool) { + // New tool added + addedTools.push(displayName); + if (newTool.enabled) { + enabledTools.push(displayName); + } else { + disabledTools.push(displayName); + } + } else if (currentTool && !newTool) { + // Tool removed + removedTools.push(displayName); + } else if (currentTool && newTool) { + // Tool modified + if (currentTool.enabled !== newTool.enabled) { + if (newTool.enabled) { + enabledTools.push(displayName); + } else { + disabledTools.push(displayName); + } + } + } + } + } + + // Build summary text + const summaryParts: string[] = []; + + if (enabledTools.length > 0) { + summaryParts.push(`✓ ENABLE (${enabledTools.length}): ${enabledTools.join(', ')}`); + } + + if (disabledTools.length > 0) { + summaryParts.push(`✗ DISABLE (${disabledTools.length}): ${disabledTools.join(', ')}`); + } + + const totalChanges = + enabledTools.length + disabledTools.length + addedTools.length + removedTools.length; + + return { + totalChanges, + summaryText: summaryParts.join('\n\n'), + }; + } + + /** + * Show detailed configuration change confirmation dialog. + * Displays specific changes between current and new MCP server configurations. + * + * @param currentConfig - Current MCP configuration, or null if none exists + * @param newConfig - New MCP configuration to be applied + * @returns Promise resolving to true if user approves changes, false otherwise + */ + private async showConfigChangeDialog( + currentConfig: MCPConfig | null, + newConfig: MCPConfig + ): Promise { + debugLog('Current MCP Config:', currentConfig); + debugLog('New MCP Config:', newConfig); + if (!this.mainWindow) { + console.warn('No main window available for confirmation dialog, allowing operation'); + return true; + } + + const changes = this.analyzeConfigChanges(currentConfig, newConfig); + + const result = await dialog.showMessageBox(this.mainWindow, { + type: 'question', + buttons: ['Apply Changes', 'Cancel'], + defaultId: 1, + title: 'MCP Configuration Changes', + message: 'The application wants to update the MCP configuration.', + detail: + changes.length > 0 + ? `The following changes will be applied:\n\n${changes.join( + '\n' + )}\n\nDo you want to apply these changes?` + : 'No changes detected in the configuration.\n\nDo you want to proceed anyway?', + }); + + return result.response === 0; // 0 is "Apply Changes" + } + + /** + * Analyze differences between current and new configuration. + * Creates a human-readable list of changes to MCP server configurations. + * + * @param currentConfig - Current MCP configuration, or null if none exists + * @param newConfig - New MCP configuration to compare against + * @returns Array of human-readable change descriptions + */ + private analyzeConfigChanges(currentConfig: MCPConfig | null, newConfig: MCPConfig): string[] { + const changes: string[] = []; + + // Check if MCP is being enabled/disabled + const currentEnabled = currentConfig?.enabled ?? false; + const newEnabled = newConfig.enabled ?? false; + + if (currentEnabled !== newEnabled) { + changes.push(`• MCP will be ${newEnabled ? 'ENABLED' : 'DISABLED'}`); + } + + // Get current and new server lists + const currentServers = currentConfig?.servers ?? []; + const newServers = newConfig.servers ?? []; + + // Check for added servers + const currentServerNames = new Set(currentServers.map(s => s.name)); + const newServerNames = new Set(newServers.map(s => s.name)); + + for (const server of newServers) { + if (!currentServerNames.has(server.name)) { + changes.push(`• ADD server: "${server.name}" (${server.command})`); + } + } + + // Check for removed servers + for (const server of currentServers) { + if (!newServerNames.has(server.name)) { + changes.push(`• REMOVE server: "${server.name}"`); + } + } + + // Check for modified servers + for (const newServer of newServers) { + const currentServer = currentServers.find(s => s.name === newServer.name); + if (currentServer) { + const serverChanges: string[] = []; + + // Check enabled status + if (currentServer.enabled !== newServer.enabled) { + serverChanges.push(`${newServer.enabled ? 'enable' : 'disable'}`); + } + + // Check command + if (currentServer.command !== newServer.command) { + serverChanges.push(`change command: "${currentServer.command}" → "${newServer.command}"`); + } + + // Check arguments + const currentArgs = JSON.stringify(currentServer.args || []); + const newArgs = JSON.stringify(newServer.args || []); + if (currentArgs !== newArgs) { + serverChanges.push(`change arguments: ${currentArgs} → ${newArgs}`); + } + + // Check environment variables + const currentEnv = JSON.stringify(currentServer.env || {}); + const newEnv = JSON.stringify(newServer.env || {}); + if (currentEnv !== newEnv) { + serverChanges.push(`change environment variables`); + } + + if (serverChanges.length > 0) { + changes.push(`• MODIFY server "${newServer.name}": ${serverChanges.join(', ')}`); + } + } + } + + return changes; + } + + /** + * Parse tool name to extract server and tool components. + * Tool names follow format "serverName__toolName" where serverName is the MCP server prefix. + * + * @param fullToolName - Complete tool name potentially including server prefix + * @returns Object with serverName and toolName components + */ + private parseToolName(fullToolName: string): { serverName: string; toolName: string } { + const parts = fullToolName.split('__'); + if (parts.length >= 2) { + return { + serverName: parts[0], + toolName: parts.slice(1).join('__'), + }; + } + return { + serverName: 'default', + toolName: fullToolName, + }; + } + + /** + * Validate tool parameters against schema from configuration. + * Performs basic JSON schema validation on tool parameters before execution. + * + * @param serverName - Name of the MCP server providing the tool + * @param toolName - Name of the tool being validated + * @param args - Tool arguments to validate + * @returns Object with validation result and optional error message + */ + private validateToolParameters( + serverName: string, + toolName: string, + args: any + ): { valid: boolean; error?: string } { + const toolState = this.configManager.getToolStats(serverName, toolName); + if (!toolState || !toolState.inputSchema) { + // No schema available, assume valid + return { valid: true }; + } + + try { + const schema = toolState.inputSchema; + + // Basic validation - check required properties + if (schema.required && Array.isArray(schema.required)) { + for (const requiredProp of schema.required) { + if (args[requiredProp] === undefined || args[requiredProp] === null) { + return { + valid: false, + error: `Required parameter '${requiredProp}' is missing`, + }; + } + } + } + + // Check property types if schema properties are defined + if (schema.properties) { + for (const [propName, propSchema] of Object.entries(schema.properties as any)) { + if (args[propName] !== undefined) { + const propType = (propSchema as any).type; + const actualType = typeof args[propName]; + + if (propType === 'string' && actualType !== 'string') { + return { + valid: false, + error: `Parameter '${propName}' should be a string, got ${actualType}`, + }; + } + if (propType === 'number' && actualType !== 'number') { + return { + valid: false, + error: `Parameter '${propName}' should be a number, got ${actualType}`, + }; + } + if (propType === 'boolean' && actualType !== 'boolean') { + return { + valid: false, + error: `Parameter '${propName}' should be a boolean, got ${actualType}`, + }; + } + if (propType === 'array' && !Array.isArray(args[propName])) { + return { + valid: false, + error: `Parameter '${propName}' should be an array, got ${actualType}`, + }; + } + if ( + propType === 'object' && + (actualType !== 'object' || Array.isArray(args[propName]) || args[propName] === null) + ) { + return { + valid: false, + error: `Parameter '${propName}' should be an object, got ${actualType}`, + }; + } + } + } + } + + return { valid: true }; + } catch (error) { + return { + valid: false, + error: `Schema validation error: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + }; + } + } + + /** + * Load MCP server configuration from settings + */ + private loadMCPConfig(): MCPConfig | null { + try { + const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + if (!fs.existsSync(settingsPath)) { + return null; + } + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + return settings.mcpConfig || null; + } catch (error) { + console.error('Error loading MCP config:', error); + return null; + } + } + + /** + * Expand environment variables and resolve paths in arguments. + * Handles: + * - Windows environment variables (%USERPROFILE%, %APPDATA%, etc.) + * - HEADLAMP_CURRENT_CLUSTER placeholder + * - Docker volume mount path conversions for Windows + * + * @param args - Array of argument strings to expand + * @param cluster - Optional cluster context to substitute + * @returns Array of expanded argument strings + */ + private expandArgs(args: string[], cluster: string | null = null): string[] { + const currentCluster = cluster || this.currentCluster || ''; + + return args.map(arg => { + // Replace Windows environment variables like %USERPROFILE% + let expandedArg = arg; + + // Handle HEADLAMP_CURRENT_CLUSTER placeholder + if (expandedArg.includes('HEADLAMP_CURRENT_CLUSTER')) { + expandedArg = expandedArg.replace(/HEADLAMP_CURRENT_CLUSTER/g, currentCluster); + } + + // Handle %USERPROFILE% + if (expandedArg.includes('%USERPROFILE%')) { + expandedArg = expandedArg.replace(/%USERPROFILE%/g, os.homedir()); + } + + // Handle other common Windows environment variables + if (expandedArg.includes('%APPDATA%')) { + expandedArg = expandedArg.replace(/%APPDATA%/g, process.env.APPDATA || ''); + } + + if (expandedArg.includes('%LOCALAPPDATA%')) { + expandedArg = expandedArg.replace(/%LOCALAPPDATA%/g, process.env.LOCALAPPDATA || ''); + } + + // Convert Windows backslashes to forward slashes for Docker + if (process.platform === 'win32' && expandedArg.includes('\\')) { + expandedArg = expandedArg.replace(/\\/g, '/'); + } + + // Handle Docker volume mount format and ensure proper Windows path format + if (expandedArg.includes('type=bind,src=')) { + // Parse Docker mount options more carefully to handle paths with commas + // Format: type=bind,src=,dst=[,other-options] + const typeBindMatch = expandedArg.match(/^type=bind,src=(.+),dst=(.+?)(?:,|$)/); + if (typeBindMatch) { + // For paths with commas, we need to find the last occurrence of ,dst= + const srcStartIdx = expandedArg.indexOf('src=') + 4; + const dstStartIdx = expandedArg.lastIndexOf(',dst='); + + if (dstStartIdx > srcStartIdx) { + let srcPath = expandedArg.substring(srcStartIdx, dstStartIdx); + const remainingPart = expandedArg.substring(dstStartIdx + 5); // Skip ",dst=" + const dstEndIdx = remainingPart.indexOf(','); + const dstPath = dstEndIdx > 0 ? remainingPart.substring(0, dstEndIdx) : remainingPart; + + // Resolve the source path + if (process.platform === 'win32') { + srcPath = path.resolve(srcPath); + // For Docker on Windows, we might need to convert C:\ to /c/ format + if (srcPath.match(/^[A-Za-z]:/)) { + srcPath = + '/' + srcPath.charAt(0).toLowerCase() + srcPath.slice(2).replace(/\\/g, '/'); + } + } + + const otherOptions = dstEndIdx > 0 ? ',' + remainingPart.substring(dstEndIdx + 1) : ''; + expandedArg = `type=bind,src=${srcPath},dst=${dstPath}${otherOptions}`; + } + } + } + + return expandedArg; + }); + } + + private async initializeClient(): Promise { + debugLog('initializeClient called'); + if (this.isInitialized) { + return; + } + + if (this.initializationPromise) { + return this.initializationPromise; + } + + debugLog('Starting MCP client initialization...'); + this.initializationPromise = this.doInitialize(); + return this.initializationPromise; + } + + private async doInitialize(): Promise { + try { + debugLog('Initializing MCP client in Electron main process...'); + + // Load MCP configuration from settings + const mcpConfig = this.loadMCPConfig(); + + if ( + !mcpConfig || + !mcpConfig.enabled || + !mcpConfig.servers || + mcpConfig.servers.length === 0 + ) { + debugLog('MCP is disabled or no servers configured'); + this.isInitialized = true; + return; + } + + // Build MCP servers configuration from settings + const mcpServers: any = {}; + + for (const server of mcpConfig.servers) { + if (!server.enabled || !server.name || !server.command) { + continue; + } + + // Expand environment variables and resolve paths in arguments + const expandedArgs = this.expandArgs(server.args || [], this.currentCluster); + console.log(`Expanded args for ${server.name}:`, expandedArgs); + + // Prepare environment variables + const serverEnv = { ...process.env, ...(server.env || {}) }; + + mcpServers[server.name] = { + transport: 'stdio', + command: server.command, + args: expandedArgs, + env: serverEnv, + restart: { + enabled: true, + maxAttempts: 3, + delayMs: 2000, + }, + }; + } + + // If no enabled servers, skip initialization + if (Object.keys(mcpServers).length === 0) { + console.log('No enabled MCP servers found'); + this.isInitialized = true; + return; + } + + console.log('Initializing MCP client with servers:', Object.keys(mcpServers)); + + this.client = new MultiServerMCPClient({ + throwOnLoadError: false, // Don't throw on load error to allow partial initialization + prefixToolNameWithServerName: true, // Prefix to avoid name conflicts + additionalToolNamePrefix: '', + useStandardContentBlocks: true, + mcpServers, + defaultToolTimeout: 2 * 60 * 1000, // 2 minutes + }); + + // Get and cache the tools + this.tools = await this.client.getTools(); + // Initialize configuration for available tools + this.initializeToolsConfiguration(); + + this.isInitialized = true; + console.log('MCP client initialized successfully with', this.tools.length, 'tools'); + } catch (error) { + console.error('Failed to initialize MCP client:', error); + this.client = null; + this.isInitialized = false; + this.initializationPromise = null; + throw error; + } + } + + private setupIpcHandlers(): void { + // Handle MCP tool execution + ipcMain.handle('mcp-execute-tool', async (event, { toolName, args, toolCallId }) => { + console.log('args in mcp-execute-tool:', args); + try { + await this.initializeClient(); + + if (!this.client || this.tools.length === 0) { + throw new Error('MCP client not initialized or no tools available'); + } + + // Parse tool name + const { serverName, toolName: actualToolName } = this.parseToolName(toolName); + + // Check if tool is enabled + const isEnabled = this.configManager.isToolEnabled(serverName, actualToolName); + + if (!isEnabled) { + throw new Error(`Tool ${actualToolName} from server ${serverName} is disabled`); + } + + // Find the tool by name + const tool = this.tools.find(t => t.name === toolName); + if (!tool) { + throw new Error(`Tool ${toolName} not found`); + } + + // Validate parameters against schema from configuration + const validation = this.validateToolParameters(serverName, actualToolName, args); + if (!validation.valid) { + throw new Error(`Parameter validation failed: ${validation.error}`); + } + + console.log(`Executing MCP tool: ${toolName} with args:`, args); + + // Execute the tool directly using LangChain's invoke method + const result = await tool.invoke(args); + console.log(`MCP tool ${toolName} executed successfully`); + + // Record tool usage + this.configManager.recordToolUsage(serverName, actualToolName); + + return { + success: true, + result, + toolCallId, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + toolCallId, + }; + } + }); + + // Handle MCP client status check + ipcMain.handle('mcp-get-status', async () => { + return { + isInitialized: this.isInitialized, + hasClient: this.client !== null, + }; + }); + + // Handle MCP client reset/restart with user confirmation + ipcMain.handle('mcp-reset-client', async () => { + try { + // Show confirmation dialog + const userConfirmed = await this.showConfirmationDialog( + 'MCP Client Reset', + 'The application wants to reset the MCP client. This will restart all MCP server connections.', + 'Reset MCP client' + ); + + if (!userConfirmed) { + return { + success: false, + error: 'User cancelled the operation', + }; + } + + console.log('Resetting MCP client...'); + + if (this.client) { + // If the client has a close/dispose method, call it + if (typeof (this.client as any).close === 'function') { + await (this.client as any).close(); + } + } + + this.client = null; + this.isInitialized = false; + this.initializationPromise = null; + + // Re-initialize + await this.initializeClient(); + + return { success: true }; + } catch (error) { + console.error('Error resetting MCP client:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + // Handle MCP configuration updates with detailed user confirmation + ipcMain.handle('mcp-update-config', async (event, mcpConfig: MCPConfig) => { + try { + // Get current configuration for comparison + const currentConfig = this.loadMCPConfig(); + console.log('Requested MCP configuration update:', mcpConfig); + // Show detailed confirmation dialog with changes + const userConfirmed = await this.showConfigChangeDialog(currentConfig, mcpConfig); + + if (!userConfirmed) { + return { + success: false, + error: 'User cancelled the configuration update', + }; + } + + console.log('Updating MCP configuration with user confirmation...'); + + // Save to settings file + const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + let settings: any = {}; + + if (fs.existsSync(settingsPath)) { + settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + } + + settings.mcpConfig = mcpConfig; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + + // Reset and reinitialize client with new config + if (this.client) { + if (typeof (this.client as any).close === 'function') { + await (this.client as any).close(); + } + } + this.client = null; + this.isInitialized = false; + this.initializationPromise = null; + + // Re-initialize with new config + await this.initializeClient(); + + console.log('MCP configuration updated successfully'); + return { success: true }; + } catch (error) { + console.error('Error updating MCP configuration:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + // Handle getting current MCP configuration + ipcMain.handle('mcp-get-config', async () => { + try { + const mcpConfig = this.loadMCPConfig(); + return { + success: true, + config: mcpConfig || { enabled: false, servers: [] }, + }; + } catch (error) { + console.error('Error getting MCP configuration:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + config: { enabled: false, servers: [] }, + }; + } + }); + + // Handle getting MCP tools configuration + ipcMain.handle('mcp-get-tools-config', async () => { + try { + const toolsConfig = this.configManager.getConfig(); + return { + success: true, + config: toolsConfig, + }; + } catch (error) { + console.error('Error getting MCP tools configuration:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + config: {}, + }; + } + }); + + // Handle updating MCP tools configuration with user confirmation + ipcMain.handle('mcp-update-tools-config', async (event, toolsConfig: any) => { + console.log('Requested MCP tools configuration update:', toolsConfig); + try { + // Show confirmation dialog with detailed changes + const userConfirmed = await this.showToolsConfigConfirmationDialog(toolsConfig); + + if (!userConfirmed) { + return { + success: false, + error: 'User cancelled the operation', + }; + } + + this.configManager.setConfig(toolsConfig); + return { success: true }; + } catch (error) { + console.error('Error updating MCP tools configuration:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + // Handle enabling/disabling specific tools + ipcMain.handle('mcp-set-tool-enabled', async (event, { serverName, toolName, enabled }) => { + try { + this.configManager.setToolEnabled(serverName, toolName, enabled); + return { success: true }; + } catch (error) { + console.error('Error setting tool enabled state:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + // Handle getting tool statistics + ipcMain.handle('mcp-get-tool-stats', async (event, { serverName, toolName }) => { + try { + const stats = this.configManager.getToolStats(serverName, toolName); + return { + success: true, + stats, + }; + } catch (error) { + console.error('Error getting tool statistics:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + stats: null, + }; + } + }); + + // Handle cluster context changes + ipcMain.handle('mcp-cluster-change', async (event, { cluster }) => { + try { + console.log('Received cluster change event:', cluster); + await this.handleClusterChange(cluster); + return { + success: true, + }; + } catch (error) { + console.error('Error handling cluster change:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + } + + /** + * Check if any server in the config uses HEADLAMP_CURRENT_CLUSTER placeholder. + * This determines whether the MCP client needs to be restarted on cluster changes. + * + * @param mcpConfig - MCP configuration to check + * @returns True if any enabled server has HEADLAMP_CURRENT_CLUSTER in its arguments + */ + private hasClusterDependentServers(mcpConfig: MCPConfig | null): boolean { + if (!mcpConfig || !mcpConfig.servers) { + return false; + } + + return mcpConfig.servers.some( + server => + server.enabled && + server.args && + server.args.some(arg => arg.includes('HEADLAMP_CURRENT_CLUSTER')) + ); + } + + /** + * Handle cluster context change + * This will restart MCP servers if any server uses HEADLAMP_CURRENT_CLUSTER + */ + async handleClusterChange(newCluster: string | null): Promise { + // If cluster hasn't actually changed, do nothing + if (this.currentCluster === newCluster) { + return; + } + + const oldCluster = this.currentCluster; + this.currentCluster = newCluster; + + // Check if we have any cluster-dependent servers + const mcpConfig = this.loadMCPConfig(); + if (!this.hasClusterDependentServers(mcpConfig)) { + console.log('No cluster-dependent MCP servers found, skipping restart'); + return; + } + + try { + // Reset the client + if (this.client) { + if (typeof (this.client as any).close === 'function') { + await (this.client as any).close(); + } + } + + this.client = null; + this.isInitialized = false; + this.initializationPromise = null; + + // Re-initialize with new cluster context + await this.initializeClient(); + + console.log('MCP client restarted successfully for new cluster:', newCluster); + } catch (error) { + console.error('Error restarting MCP client for cluster change:', error); + // Restore previous cluster on error + this.currentCluster = oldCluster; + throw error; + } + } + + /** + * Public method to initialize the MCP client + * This should be called when the app starts + */ + async initialize(): Promise { + try { + await this.initializeClient(); + } catch (error) { + console.error('Failed to initialize MCP client on startup:', error); + // Don't throw error to prevent app startup failure + } + } + + /** + * Cleanup method to be called when the app is shutting down + */ + async cleanup(): Promise { + if (this.client) { + try { + await this.client.close(); + } catch (error) { + console.error('Error cleaning up MCP client:', error); + } + } + this.client = null; + this.tools = []; + this.isInitialized = false; + this.initializationPromise = null; + } +} + +export default ElectronMCPClient; diff --git a/app/electron/mcp-config.ts b/app/electron/mcp-config.ts new file mode 100644 index 00000000000..209b8180ba3 --- /dev/null +++ b/app/electron/mcp-config.ts @@ -0,0 +1,303 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { app } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; + +export interface MCPToolState { + enabled: boolean; + lastUsed?: Date; + usageCount?: number; + inputSchema?: any; // JSON schema for tool parameters + description?: string; // Tool description from MCP server +} + +export interface MCPServerToolState { + [toolName: string]: MCPToolState; +} + +export interface MCPToolsConfig { + [serverName: string]: MCPServerToolState; +} + +export class MCPConfigManager { + private configPath: string; + private config: MCPToolsConfig = {}; + + constructor() { + this.configPath = path.join(app.getPath('userData'), 'headlamp-mcp-config.json'); + this.loadConfig(); + } + + /** + * Load MCP tools configuration from file + */ + private loadConfig(): void { + try { + if (fs.existsSync(this.configPath)) { + const configData = fs.readFileSync(this.configPath, 'utf-8'); + this.config = JSON.parse(configData); + } else { + this.config = {}; + } + } catch (error) { + console.error('Error loading MCP tools configuration:', error); + this.config = {}; + } + } + + /** + * Save MCP tools configuration to file + */ + private saveConfig(): void { + try { + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8'); + } catch (error) { + console.error('Error saving MCP tools configuration:', error); + } + } + + /** + * Get the enabled state of a specific tool + */ + isToolEnabled(serverName: string, toolName: string): boolean { + const serverConfig = this.config[serverName]; + if (!serverConfig) { + // Default to enabled for new tools + return true; + } + + const toolState = serverConfig[toolName]; + if (!toolState) { + // Default to enabled for new tools + return true; + } + + return toolState.enabled; + } + + /** + * Set the enabled state of a specific tool + */ + setToolEnabled(serverName: string, toolName: string, enabled: boolean): void { + if (!this.config[serverName]) { + this.config[serverName] = {}; + } + + if (!this.config[serverName][toolName]) { + this.config[serverName][toolName] = { + enabled: true, + usageCount: 0, + }; + } + + this.config[serverName][toolName].enabled = enabled; + this.saveConfig(); + } + + /** + * Get all disabled tools for a server + */ + getDisabledTools(serverName: string): string[] { + const serverConfig = this.config[serverName]; + if (!serverConfig) { + return []; + } + + return Object.entries(serverConfig) + .filter(([, toolState]) => !toolState.enabled) + .map(([toolName]) => toolName); + } + + /** + * Get all enabled tools for a server + */ + getEnabledTools(serverName: string): string[] { + const serverConfig = this.config[serverName]; + if (!serverConfig) { + return []; + } + + return Object.entries(serverConfig) + .filter(([, toolState]) => toolState.enabled) + .map(([toolName]) => toolName); + } + + /** + * Update tool usage statistics + */ + recordToolUsage(serverName: string, toolName: string): void { + if (!this.config[serverName]) { + this.config[serverName] = {}; + } + + if (!this.config[serverName][toolName]) { + this.config[serverName][toolName] = { + enabled: true, + usageCount: 0, + }; + } + + const toolState = this.config[serverName][toolName]; + toolState.lastUsed = new Date(); + toolState.usageCount = (toolState.usageCount || 0) + 1; + this.saveConfig(); + } + + /** + * Get the complete configuration + */ + getConfig(): MCPToolsConfig { + return { ...this.config }; + } + + /** + * Set the complete configuration + */ + setConfig(newConfig: MCPToolsConfig): void { + this.config = { ...newConfig }; + this.saveConfig(); + } + + /** + * Reset configuration to empty state + */ + resetConfig(): void { + this.config = {}; + this.saveConfig(); + } + + /** + * Initialize default configuration for available tools with schemas + */ + initializeToolsConfig( + serverName: string, + toolsInfo: Array<{ + name: string; + inputSchema?: any; + description?: string; + }> + ): void { + if (!this.config[serverName]) { + this.config[serverName] = {}; + } + + const serverConfig = this.config[serverName]; + let hasChanges = false; + + for (const toolInfo of toolsInfo) { + const toolName = toolInfo.name; + + if (!serverConfig[toolName]) { + serverConfig[toolName] = { + enabled: true, + usageCount: 0, + inputSchema: toolInfo.inputSchema || null, + description: toolInfo.description || '', + }; + hasChanges = true; + } else { + // Always update schema and description for existing tools + let toolChanged = false; + + // Update schema if it's different or missing + const currentSchema = JSON.stringify(serverConfig[toolName].inputSchema || null); + const newSchema = JSON.stringify(toolInfo.inputSchema || null); + if (currentSchema !== newSchema) { + serverConfig[toolName].inputSchema = toolInfo.inputSchema || null; + toolChanged = true; + } + + // Update description if it's different or missing + const currentDescription = serverConfig[toolName].description || ''; + const newDescription = toolInfo.description || ''; + if (currentDescription !== newDescription) { + serverConfig[toolName].description = newDescription; + toolChanged = true; + } + + if (toolChanged) { + hasChanges = true; + } + } + } + + if (hasChanges) { + this.saveConfig(); + } + } + + /** + * Get tool statistics + */ + getToolStats(serverName: string, toolName: string): MCPToolState | null { + const serverConfig = this.config[serverName]; + if (!serverConfig || !serverConfig[toolName]) { + return null; + } + + return { ...serverConfig[toolName] }; + } + + /** + * Replace the entire tools configuration with a new set of tools + * This overwrites all existing tools with only the current ones + */ + replaceToolsConfig( + toolsByServer: Record< + string, + Array<{ + name: string; + inputSchema?: any; + description?: string; + }> + > + ): void { + // Create a new config object + const newConfig: MCPToolsConfig = {}; + + for (const [serverName, toolsInfo] of Object.entries(toolsByServer)) { + newConfig[serverName] = {}; + + for (const toolInfo of toolsInfo) { + const toolName = toolInfo.name; + + // Check if this tool existed in the old config to preserve enabled state and usage count + const oldToolState = this.config[serverName]?.[toolName]; + + newConfig[serverName][toolName] = { + enabled: oldToolState?.enabled ?? true, // Preserve enabled state or default to true + usageCount: oldToolState?.usageCount ?? 0, // Preserve usage count or default to 0 + inputSchema: toolInfo.inputSchema || null, + description: toolInfo.description || '', + }; + } + } + + // Replace the entire config + this.config = newConfig; + this.saveConfig(); + } + + /** + * Replace the entire configuration with a new config object + */ + replaceConfig(newConfig: MCPToolsConfig): void { + this.config = newConfig; + this.saveConfig(); + } +} diff --git a/app/electron/preload.ts b/app/electron/preload.ts index e65c2c13404..c6e7b9545fa 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -30,6 +30,7 @@ contextBridge.exposeInMainWorld('desktopApi', { 'plugin-manager', 'request-backend-token', 'request-plugin-permission-secrets', + 'cluster-changed', ]; if (validChannels.includes(channel)) { ipcRenderer.send(channel, data); @@ -47,6 +48,7 @@ contextBridge.exposeInMainWorld('desktopApi', { 'plugin-manager', 'backend-token', 'plugin-permission-secrets', + 'open-about-dialog', ]; if (validChannels.includes(channel)) { // Deliberately strip event as it includes `sender` @@ -57,4 +59,27 @@ contextBridge.exposeInMainWorld('desktopApi', { removeListener: (channel: string, func: (...args: unknown[]) => void) => { ipcRenderer.removeListener(channel, func); }, + + // MCP client APIs + mcp: { + executeTool: (toolName: string, args: Record, toolCallId?: string) => + ipcRenderer.invoke('mcp-execute-tool', { toolName, args, toolCallId }), + getStatus: () => ipcRenderer.invoke('mcp-get-status'), + resetClient: () => ipcRenderer.invoke('mcp-reset-client'), + getConfig: () => ipcRenderer.invoke('mcp-get-config'), + updateConfig: (config: any) => ipcRenderer.invoke('mcp-update-config', config), + getToolsConfig: () => ipcRenderer.invoke('mcp-get-tools-config'), + updateToolsConfig: (config: any) => ipcRenderer.invoke('mcp-update-tools-config', config), + setToolEnabled: (serverName: string, toolName: string, enabled: boolean) => + ipcRenderer.invoke('mcp-set-tool-enabled', { serverName, toolName, enabled }), + getToolStats: (serverName: string, toolName: string) => + ipcRenderer.invoke('mcp-get-tool-stats', { serverName, toolName }), + clusterChange: (cluster: string | null) => + ipcRenderer.invoke('mcp-cluster-change', { cluster }), + }, + + // Notify cluster change (for MCP server restart) + notifyClusterChange: (cluster: string | null) => { + ipcRenderer.send('cluster-changed', cluster); + }, }); diff --git a/app/package-lock.json b/app/package-lock.json index 7f7d034d619..ff1e2eee32e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,13 +1,14 @@ { "name": "headlamp", - "version": "0.36.0", + "version": "0.37.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "headlamp", - "version": "0.36.0", + "version": "0.37.0", "dependencies": { + "@langchain/mcp-adapters": "^0.6.0", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "dotenv": "^16.4.5", @@ -1958,6 +1959,13 @@ "hasInstallScript": true, "optional": true }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT", + "peer": true + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -3495,6 +3503,76 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@langchain/core": { + "version": "0.3.73", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.73.tgz", + "integrity": "sha512-E5dK9/MDH9671yuU3ZoMfSkMC7njtZrOZYrmLGk+2cvGk92yqxv/+MMxwOYoFtPMEWx9T8mTg2omHqcXXaEpGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": "^0.3.46", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^10.0.0", + "zod": "^3.25.32", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@langchain/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@langchain/core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/mcp-adapters": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@langchain/mcp-adapters/-/mcp-adapters-0.6.0.tgz", + "integrity": "sha512-NHQNH9NciLhxlCnL/4HDebiYT3UQvpBfF5KPlIi/uSXn8te/bYjPV64gUyAloNNo+fjj4qDvKP1/nHj0r7fKFw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "debug": "^4.4.0", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "extended-eventsource": "^1.x" + }, + "peerDependencies": { + "@langchain/core": "^0.3.66" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", @@ -3567,6 +3645,29 @@ "node": ">= 10.0.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", + "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.38.7", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.7.tgz", @@ -3857,6 +3958,13 @@ "@types/node": "*" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/semver": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", @@ -3876,6 +3984,13 @@ "integrity": "sha512-Lja2xYuuf2B3knEsga8ShbOdsfNOtzT73GyJmZyY7eGl2+ajOqrs8yM5ze0fsSoYwvA6bw7/Qr7OZ7PEEmYwWg==", "dev": true }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT", + "peer": true + }, "node_modules/@types/verror": { "version": "1.10.9", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz", @@ -4138,6 +4253,40 @@ "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", "dev": true }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -4175,7 +4324,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5031,7 +5179,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -5099,6 +5246,26 @@ "bluebird": "^3.5.5" } }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -5382,6 +5549,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -5406,7 +5582,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5416,6 +5591,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5784,12 +5975,81 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/console-table-printer": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.14.6.tgz", + "integrity": "sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "simple-wcswidth": "^1.0.1" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/copyfiles": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", @@ -5901,6 +6161,19 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -6124,11 +6397,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -6139,6 +6413,16 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -6287,6 +6571,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6510,7 +6803,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6526,6 +6818,12 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6812,6 +7110,15 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", @@ -6939,7 +7246,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6949,7 +7255,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -7012,7 +7317,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7121,6 +7425,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -7697,6 +8007,43 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT", + "peer": true + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7757,6 +8104,91 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extended-eventsource": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extended-eventsource/-/extended-eventsource-1.7.0.tgz", + "integrity": "sha512-s8rtvZuYcKBpzytHb5g95cHbZ1J99WeMnV18oKc5wKoxkHzlzpPc/bNAm7Da2Db0BDw0CAu1z3LpH+7UsyzIpw==", + "license": "MIT", + "optional": true + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -7790,8 +8222,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -7817,8 +8248,7 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -7905,6 +8335,23 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-process": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.10.tgz", @@ -8012,6 +8459,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -8182,7 +8647,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8216,7 +8680,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8420,7 +8883,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8506,7 +8968,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8625,6 +9086,31 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -8803,7 +9289,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8931,6 +9416,15 @@ "node": ">= 0.10" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -9265,6 +9759,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -10224,6 +10724,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "peer": true, + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10270,8 +10780,7 @@ "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -10347,6 +10856,42 @@ "node": ">=6" } }, + "node_modules/langsmith": { + "version": "0.3.67", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.67.tgz", + "integrity": "sha512-l4y3RmJ9yWF5a29fLg3eWZQxn6Q6dxTOgLGgQHzPGZHF3NUynn+A+airYIe/Yt4rwjGbuVrABAPsXBkVu/Hi7g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "p-retry": "4", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -10604,12 +11149,32 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -10828,9 +11393,20 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "peer": true, + "bin": { + "mustache": "bin/mustache" + } }, "node_modules/natural-compare": { "version": "1.4.0", @@ -10838,6 +11414,15 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -10955,17 +11540,15 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "dev": true, - "peer": true, + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -11085,6 +11668,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11180,6 +11775,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -11264,6 +11913,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11323,6 +11981,16 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -11364,6 +12032,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -11534,6 +12211,19 @@ "node": ">= 8" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -11547,7 +12237,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, "engines": { "node": ">=6" } @@ -11568,6 +12257,21 @@ } ] }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11649,6 +12353,46 @@ "rimraf": "bin.js" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rcedit": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-4.0.1.tgz", @@ -12047,6 +12791,22 @@ "node": ">=8.0" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -12130,8 +12890,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sanitize-filename": { "version": "1.6.3", @@ -12149,9 +12908,10 @@ "dev": true }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -12166,6 +12926,49 @@ "dev": true, "optional": true }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -12182,6 +12985,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12216,6 +13034,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12376,16 +13200,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, - "peer": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -12411,6 +13288,13 @@ "node": ">=10" } }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "license": "MIT", + "peer": true + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -12513,6 +13397,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -13013,6 +13906,15 @@ "node": ">=10.13.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -13104,6 +14006,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", @@ -13289,6 +14226,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -13331,7 +14277,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -13347,6 +14292,20 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -13370,6 +14329,15 @@ "node": ">= 10.13.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -13874,6 +14842,24 @@ "engines": { "node": ">= 6" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } } }, "dependencies": { @@ -15209,6 +16195,12 @@ "integrity": "sha512-iTZ8cVGZ5dglNRyFdSj8U60mHIrC8XNIuOHN/NkM5/dQP4nsmpyqeQTAADLLQgoFCNJD+DiwQCv8dR2cCeWP4g==", "optional": true }, + "@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "peer": true + }, "@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -16205,6 +17197,51 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@langchain/core": { + "version": "0.3.73", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.73.tgz", + "integrity": "sha512-E5dK9/MDH9671yuU3ZoMfSkMC7njtZrOZYrmLGk+2cvGk92yqxv/+MMxwOYoFtPMEWx9T8mTg2omHqcXXaEpGw==", + "peer": true, + "requires": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": "^0.3.46", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^10.0.0", + "zod": "^3.25.32", + "zod-to-json-schema": "^3.22.3" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "peer": true + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "peer": true + } + } + }, + "@langchain/mcp-adapters": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@langchain/mcp-adapters/-/mcp-adapters-0.6.0.tgz", + "integrity": "sha512-NHQNH9NciLhxlCnL/4HDebiYT3UQvpBfF5KPlIi/uSXn8te/bYjPV64gUyAloNNo+fjj4qDvKP1/nHj0r7fKFw==", + "requires": { + "@modelcontextprotocol/sdk": "^1.12.1", + "debug": "^4.4.0", + "extended-eventsource": "^1.x", + "zod": "^3.24.2" + } + }, "@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", @@ -16255,6 +17292,25 @@ } } }, + "@modelcontextprotocol/sdk": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", + "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", + "requires": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + } + }, "@mswjs/interceptors": { "version": "0.38.7", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.7.tgz", @@ -16529,6 +17585,12 @@ "@types/node": "*" } }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "peer": true + }, "@types/semver": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", @@ -16547,6 +17609,12 @@ "integrity": "sha512-Lja2xYuuf2B3knEsga8ShbOdsfNOtzT73GyJmZyY7eGl2+ajOqrs8yM5ze0fsSoYwvA6bw7/Qr7OZ7PEEmYwWg==", "dev": true }, + "@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "peer": true + }, "@types/verror": { "version": "1.10.9", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz", @@ -16716,6 +17784,30 @@ "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", "dev": true }, + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, "acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -16742,7 +17834,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -17415,8 +18506,7 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "binary-extensions": { "version": "2.2.0", @@ -17466,6 +18556,22 @@ "bluebird": "^3.5.5" } }, + "body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "requires": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + } + }, "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -17678,6 +18784,11 @@ "sax": "^1.2.4" } }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, "call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -17696,12 +18807,20 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -17971,12 +19090,51 @@ } } }, + "console-table-printer": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.14.6.tgz", + "integrity": "sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==", + "peer": true, + "requires": { + "simple-wcswidth": "^1.0.1" + } + }, + "content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "requires": { + "safe-buffer": "5.2.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, "copyfiles": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", @@ -18063,6 +19221,15 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -18216,13 +19383,19 @@ } }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "peer": true + }, "decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -18329,6 +19502,11 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -18495,7 +19673,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -18507,6 +19684,11 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, "ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -18740,6 +19922,11 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, "encoding-sniffer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", @@ -18849,14 +20036,12 @@ "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" }, "es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" }, "es-get-iterator": { "version": "1.1.3", @@ -18912,7 +20097,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "requires": { "es-errors": "^1.3.0" } @@ -18996,6 +20180,11 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==" }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -19423,6 +20612,30 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "peer": true + }, + "eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "requires": { + "eventsource-parser": "^3.0.1" + } + }, + "eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==" + }, "execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -19467,6 +20680,67 @@ "jest-util": "^29.7.0" } }, + "express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "requires": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, + "express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "requires": {} + }, + "extended-eventsource": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extended-eventsource/-/extended-eventsource-1.7.0.tgz", + "integrity": "sha512-s8rtvZuYcKBpzytHb5g95cHbZ1J99WeMnV18oKc5wKoxkHzlzpPc/bNAm7Da2Db0BDw0CAu1z3LpH+7UsyzIpw==", + "optional": true + }, "extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -19489,8 +20763,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-fifo": { "version": "1.3.2", @@ -19513,8 +20786,7 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-levenshtein": { "version": "2.0.6", @@ -19594,6 +20866,19 @@ "to-regex-range": "^5.0.1" } }, + "finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "requires": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + } + }, "find-process": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.10.tgz", @@ -19677,6 +20962,16 @@ "mime-types": "^2.1.12" } }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -19809,7 +21104,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -19833,7 +21127,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "requires": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -19973,8 +21266,7 @@ "gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" }, "graceful-fs": { "version": "4.2.11", @@ -20039,8 +21331,7 @@ "has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, "has-tostringtag": { "version": "1.0.2", @@ -20136,6 +21427,25 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "dependencies": { + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + } + } + }, "https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -20261,7 +21571,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3.0.0" } @@ -20343,6 +21652,11 @@ "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==" }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, "is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -20557,6 +21871,11 @@ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true }, + "is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, "is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -21272,6 +22591,15 @@ } } }, + "js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "peer": true, + "requires": { + "base64-js": "^1.5.1" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -21309,8 +22637,7 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -21371,6 +22698,21 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "langsmith": { + "version": "0.3.67", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.67.tgz", + "integrity": "sha512-l4y3RmJ9yWF5a29fLg3eWZQxn6Q6dxTOgLGgQHzPGZHF3NUynn+A+airYIe/Yt4rwjGbuVrABAPsXBkVu/Hi7g==", + "peer": true, + "requires": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "p-retry": "4", + "semver": "^7.6.3", + "uuid": "^10.0.0" + } + }, "language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -21573,8 +22915,17 @@ "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, "merge-stream": { "version": "2.0.0", @@ -21716,9 +23067,15 @@ "dev": true }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "peer": true }, "natural-compare": { "version": "1.4.0", @@ -21726,6 +23083,11 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -21828,15 +23190,12 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "dev": true, - "peer": true + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, "object-is": { "version": "1.1.6", @@ -21917,6 +23276,14 @@ "es-object-atoms": "^1.0.0" } }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -21987,6 +23354,43 @@ } } }, + "p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "peer": true, + "requires": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + } + }, + "p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "peer": true, + "requires": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "dependencies": { + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "peer": true + } + } + }, + "p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "peer": true, + "requires": { + "p-finally": "^1.0.0" + } + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -22047,6 +23451,11 @@ "parse5": "^7.0.0" } }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -22090,6 +23499,11 @@ } } }, + "path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==" + }, "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -22119,6 +23533,11 @@ "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", "dev": true }, + "pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==" + }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -22249,6 +23668,15 @@ "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==" }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -22261,8 +23689,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "pure-rand": { "version": "6.1.0", @@ -22270,6 +23697,14 @@ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "dev": true }, + "qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "requires": { + "side-channel": "^1.1.0" + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -22323,6 +23758,32 @@ } } }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "rcedit": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-4.0.1.tgz", @@ -22640,6 +24101,18 @@ "sprintf-js": "^1.1.2" } }, + "router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "requires": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + } + }, "rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -22696,8 +24169,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sanitize-filename": { "version": "1.6.3", @@ -22715,9 +24187,9 @@ "dev": true }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==" + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==" }, "semver-compare": { "version": "1.0.0", @@ -22726,6 +24198,39 @@ "dev": true, "optional": true }, + "send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "requires": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, "serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -22736,6 +24241,17 @@ "type-fest": "^0.13.1" } }, + "serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "requires": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + } + }, "set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -22764,6 +24280,11 @@ "has-property-descriptors": "^1.0.2" } }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -22878,16 +24399,47 @@ } }, "side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, - "peer": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "requires": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" } }, "signal-exit": { @@ -22904,6 +24456,12 @@ "semver": "^7.5.3" } }, + "simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "peer": true + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -22981,6 +24539,11 @@ "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", "dev": true }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + }, "stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -23380,6 +24943,11 @@ "streamx": "^2.12.5" } }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, "truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -23450,6 +25018,31 @@ "dev": true, "optional": true }, + "type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "requires": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, "typed-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", @@ -23581,6 +25174,11 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, "untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -23600,7 +25198,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "requires": { "punycode": "^2.1.0" } @@ -23616,6 +25213,12 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "peer": true + }, "v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -23633,6 +25236,11 @@ "integrity": "sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==", "dev": true }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, "verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -24019,6 +25627,17 @@ } } } + }, + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + }, + "zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "requires": {} } } } diff --git a/app/package.json b/app/package.json index 084aba83d76..9c378222e73 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "headlamp", - "version": "0.36.0", + "version": "0.37.0", "description": "Easy-to-use and extensible Kubernetes web UI", "main": "electron/main.js", "homepage": "https://github.com/kubernetes-sigs/headlamp/#readme", @@ -118,6 +118,7 @@ "files": [ "electron/main.js", "electron/preload.js", + "electron/mcp-client.js", "electron/i18next.config.js", "electron/i18n-helper.js", "electron/windowSize.js", @@ -171,6 +172,7 @@ "typescript": "5.5.4" }, "dependencies": { + "@langchain/mcp-adapters": "^0.6.0", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "dotenv": "^16.4.5", diff --git a/app/windows/chocolatey/headlamp.nuspec b/app/windows/chocolatey/headlamp.nuspec index e4ce6d35d89..a695efbb121 100644 --- a/app/windows/chocolatey/headlamp.nuspec +++ b/app/windows/chocolatey/headlamp.nuspec @@ -3,7 +3,7 @@ headlamp - 0.36.0 + 0.37.0 https://github.com/kubernetes-sigs/headlamp/tree/main/app/windows/chocolatey Headlamp Kinvolk diff --git a/app/windows/chocolatey/tools/chocolateyinstall.ps1 b/app/windows/chocolatey/tools/chocolateyinstall.ps1 index c18103bbf7e..e7e4b2ac6d8 100644 --- a/app/windows/chocolatey/tools/chocolateyinstall.ps1 +++ b/app/windows/chocolatey/tools/chocolateyinstall.ps1 @@ -1,8 +1,8 @@ $ErrorActionPreference = 'Stop'; # stop on all errors $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" -$headlampVersion = '0.36.0' +$headlampVersion = '0.37.0' $url = "https://github.com/kubernetes-sigs/headlamp/releases/download/v${headlampVersion}/Headlamp-${headlampVersion}-win-x64.exe" -$checksum = '612678fabbc41bac8bae4b14e4cbbb4e888f77d24d97b5f125f44d4154648553' +$checksum = 'c48779c5f7da1eeb0eeef636db61a029519f35219206c727d809d39e79453612' $packageArgs = @{ packageName = $env:ChocolateyPackageName diff --git a/backend/README.md b/backend/README.md index 10aab9e198c..c537e54a90f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,8 @@ # Quickstart ```bash -make backend -make run-backend +npm run backend:build +npm run backend:start ``` See more detailed [Headlamp backend documentation on the web]( diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index 17d47378099..4bda7d1ce67 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -20,6 +20,7 @@ import ( "bytes" "compress/gzip" "context" + "crypto/rand" "crypto/tls" "encoding/base64" "encoding/json" @@ -34,6 +35,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "syscall" "time" @@ -79,11 +81,20 @@ type HeadlampConfig struct { oidcUseAccessToken bool oidcSkipTLSVerify bool oidcCACert string + oidcUsePKCE bool cache cache.Cache[interface{}] multiplexer *Multiplexer telemetryConfig cfg.Config oidcScopes []string telemetryHandler *telemetry.RequestHandler + // meUsernamePaths lists the JMESPath expressions tried for the username in /clusters/{cluster}/me. + meUsernamePaths string + // meEmailPaths lists the JMESPath expressions tried for the email in /clusters/{cluster}/me. + meEmailPaths string + // meGroupsPaths lists the JMESPath expressions tried for the groups in /clusters/{cluster}/me. + meGroupsPaths string + // meUserInfoURL is the URL to fetch additional user info for the /me endpoint. /oauth2/userinfo + meUserInfoURL string } const DrainNodeCacheTTL = 20 // seconds @@ -111,9 +122,11 @@ type clientConfig struct { } type OauthConfig struct { - Config *oauth2.Config - Verifier *oidc.IDTokenVerifier - Ctx context.Context + Config *oauth2.Config + Verifier *oidc.IDTokenVerifier + Ctx context.Context + CodeVerifier string // PKCE code verifier + Cluster string // cluster context name this is associated with } // returns True if a file exists. @@ -379,6 +392,13 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { logger.Log(logger.LevelInfo, nil, nil, "Proxy URLs: "+fmt.Sprint(config.ProxyURLs)) logger.Log(logger.LevelInfo, nil, nil, "TLS certificate path: "+config.TLSCertPath) logger.Log(logger.LevelInfo, nil, nil, "TLS key path: "+config.TLSKeyPath) + logger.Log(logger.LevelInfo, nil, nil, "me Username Paths: "+config.meUsernamePaths) + logger.Log(logger.LevelInfo, nil, nil, "me Email Paths: "+config.meEmailPaths) + logger.Log(logger.LevelInfo, nil, nil, "me Groups Paths: "+config.meGroupsPaths) + logger.Log(logger.LevelInfo, nil, nil, "me User Info URL: "+config.meUserInfoURL) + logger.Log(logger.LevelInfo, nil, nil, "Base URL: "+config.BaseURL) + logger.Log(logger.LevelInfo, nil, nil, "Use In Cluster: "+fmt.Sprint(config.UseInCluster)) + logger.Log(logger.LevelInfo, nil, nil, "Watch Plugins Changes: "+fmt.Sprint(config.WatchPluginsChanges)) plugins.PopulatePluginsCache(config.StaticPluginDir, config.PluginDir, config.cache) @@ -477,6 +497,16 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { portforward.GetPortForwardByID(config.cache, w, r) }).Methods("GET") + // Expose user info so the frontend can show the current user in the top bar using the per-cluster auth cookie. + r.HandleFunc("/clusters/{clusterName}/me", + auth.HandleMe(auth.MeHandlerOptions{ + UsernamePaths: config.meUsernamePaths, + EmailPaths: config.meEmailPaths, + GroupsPaths: config.meGroupsPaths, + UserInfoURL: config.meUserInfoURL, + }), + ).Methods("GET") + config.handleClusterRequests(r) r.HandleFunc("/externalproxy", func(w http.ResponseWriter, r *http.Request) { @@ -600,7 +630,10 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { config.addClusterSetupRoute(r) - oauthRequestMap := make(map[string]*OauthConfig) + var ( + oauthRequestMap = make(map[string]*OauthConfig) + oauthMu sync.Mutex + ) r.HandleFunc("/oidc", func(w http.ResponseWriter, r *http.Request) { ctx := context.Background() @@ -665,12 +698,38 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { RedirectURL: getOidcCallbackURL(r, config), Scopes: append([]string{oidc.ScopeOpenID}, oidcAuthConfig.Scopes...), } - /* we encode the cluster to base64 and set it as state so that when getting redirected - by oidc we can use this state value to get cluster name - */ - state := base64.StdEncoding.EncodeToString([]byte(cluster)) - oauthRequestMap[state] = &OauthConfig{Config: oauthConfig, Verifier: verifier, Ctx: ctx} - http.Redirect(w, r, oauthConfig.AuthCodeURL(state), http.StatusFound) + + // state should be unique per request, cryptographically secure random, url safe + state := func() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) + }() + + entry := &OauthConfig{ + Config: oauthConfig, + Verifier: verifier, + Ctx: ctx, + Cluster: cluster, + } + + var authURL string + + if config.oidcUsePKCE { + entry.CodeVerifier = oauth2.GenerateVerifier() + authURL = oauthConfig.AuthCodeURL(state, oauth2.S256ChallengeOption(entry.CodeVerifier)) + } else { + authURL = oauthConfig.AuthCodeURL(state) + } + + // Store the request config keyed by state for callback handling + oauthMu.Lock() + oauthRequestMap[state] = entry + oauthMu.Unlock() + + http.Redirect(w, r, authURL, http.StatusFound) }).Queries("cluster", "{cluster}") r.HandleFunc("/drain-node", config.handleNodeDrain).Methods("POST") @@ -680,93 +739,114 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { r.HandleFunc("/oidc-callback", func(w http.ResponseWriter, r *http.Request) { state := r.URL.Query().Get("state") - decodedState, err := base64.StdEncoding.DecodeString(state) - if err != nil { - logger.Log(logger.LevelError, nil, err, "failed to decode state") - http.Error(w, "wrong state set, invalid request "+err.Error(), http.StatusBadRequest) + if state == "" { + logger.Log(logger.LevelError, nil, err, "invalid request state is empty") + http.Error(w, "invalid request state is empty", http.StatusBadRequest) return } - if state == "" { - logger.Log(logger.LevelError, nil, err, "invalid request state is empty") - http.Error(w, "invalid request state is empty", http.StatusBadRequest) + oauthMu.Lock() + + oauthConfig, ok := oauthRequestMap[state] + if ok { + // We have a copy of the oauthConfig, we can delete the map entry now + delete(oauthRequestMap, state) + } + + oauthMu.Unlock() + if !ok { + http.Error(w, "invalid request", http.StatusBadRequest) return } - //nolint:nestif - if oauthConfig, ok := oauthRequestMap[state]; ok { - oauth2Token, err := oauthConfig.Config.Exchange(oauthConfig.Ctx, r.URL.Query().Get("code")) - if err != nil { - logger.Log(logger.LevelError, nil, err, "failed to exchange token") - http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) + var oauth2Token *oauth2.Token - return - } + var err error - tokenType := "id_token" - if config.oidcUseAccessToken { - tokenType = "access_token" - } + // Exchange authorization code for token, with or without PKCE + if config.oidcUsePKCE && oauthConfig.CodeVerifier != "" { + // Use PKCE code verifier for token exchange + oauth2Token, err = oauthConfig.Config.Exchange( + oauthConfig.Ctx, + r.URL.Query().Get("code"), + oauth2.SetAuthURLParam("code_verifier", oauthConfig.CodeVerifier), + ) + } else { + // Standard token exchange without PKCE + oauth2Token, err = oauthConfig.Config.Exchange( + oauthConfig.Ctx, + r.URL.Query().Get("code"), + ) + } - rawUserToken, ok := oauth2Token.Extra(tokenType).(string) - if !ok { - logger.Log(logger.LevelError, nil, err, fmt.Sprintf("no %s field in oauth2 token", tokenType)) - http.Error(w, fmt.Sprintf("No %s field in oauth2 token.", tokenType), http.StatusInternalServerError) + if err != nil { + logger.Log(logger.LevelError, nil, err, "failed to exchange token") + http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) - return - } + return + } - if err := config.cache.Set(context.Background(), - fmt.Sprintf("oidc-token-%s", rawUserToken), oauth2Token.RefreshToken); err != nil { - logger.Log(logger.LevelError, nil, err, "failed to cache refresh token") - http.Error(w, "Failed to cache refresh token: "+err.Error(), http.StatusInternalServerError) + tokenType := "id_token" + if config.oidcUseAccessToken { + tokenType = "access_token" + } - return - } + rawUserToken, ok := oauth2Token.Extra(tokenType).(string) + if !ok { + logger.Log(logger.LevelError, nil, err, fmt.Sprintf("no %s field in oauth2 token", tokenType)) + http.Error(w, fmt.Sprintf("No %s field in oauth2 token.", tokenType), http.StatusInternalServerError) - idToken, err := oauthConfig.Verifier.Verify(oauthConfig.Ctx, rawUserToken) - if err != nil { - logger.Log(logger.LevelError, nil, err, "failed to verify ID Token") - http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError) + return + } - return - } + if err := config.cache.Set(context.Background(), + fmt.Sprintf("oidc-token-%s", rawUserToken), oauth2Token.RefreshToken); err != nil { + logger.Log(logger.LevelError, nil, err, "failed to cache refresh token") + http.Error(w, "Failed to cache refresh token: "+err.Error(), http.StatusInternalServerError) - resp := struct { - OAuth2Token *oauth2.Token - IDTokenClaims *json.RawMessage // ID Token payload is just JSON. - }{oauth2Token, new(json.RawMessage)} + return + } - if err := idToken.Claims(&resp.IDTokenClaims); err != nil { - logger.Log(logger.LevelError, nil, err, "failed to get id token claims") - http.Error(w, err.Error(), http.StatusInternalServerError) + idToken, err := oauthConfig.Verifier.Verify(oauthConfig.Ctx, rawUserToken) + if err != nil { + logger.Log(logger.LevelError, nil, err, "failed to verify ID Token") + http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError) - return - } + return + } - var redirectURL string - if config.DevMode { - redirectURL = "http://localhost:3000/" - } else { - redirectURL = "/" - } + resp := struct { + OAuth2Token *oauth2.Token + IDTokenClaims *json.RawMessage // ID Token payload is just JSON. + }{oauth2Token, new(json.RawMessage)} - baseURL := strings.Trim(config.BaseURL, "/") - if baseURL != "" { - redirectURL += baseURL + "/" - } + if err := idToken.Claims(&resp.IDTokenClaims); err != nil { + logger.Log(logger.LevelError, nil, err, "failed to get id token claims") + http.Error(w, err.Error(), http.StatusInternalServerError) - // Set auth cookie - auth.SetTokenCookie(w, r, string(decodedState), rawUserToken, config.BaseURL) + return + } - redirectURL += fmt.Sprintf("auth?cluster=%1s", decodedState) - http.Redirect(w, r, redirectURL, http.StatusSeeOther) + var redirectURL string + if config.DevMode { + redirectURL = "http://localhost:3000/" } else { - http.Error(w, "invalid request", http.StatusBadRequest) - return + redirectURL = "/" + } + + baseURL := strings.Trim(config.BaseURL, "/") + if baseURL != "" { + redirectURL += baseURL + "/" } + + // Set auth cookie + auth.SetTokenCookie(w, r, oauthConfig.Cluster, rawUserToken, config.BaseURL) + + redirectURL += fmt.Sprintf("auth?cluster=%1s", oauthConfig.Cluster) + + http.Redirect(w, r, redirectURL, http.StatusSeeOther) }) // Serve the frontend if needed diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 72739ff7c39..73b434746e6 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -61,32 +61,52 @@ func main() { StartHeadlampServer(headlampConfig) } +// buildHeadlampCFG maps the parsed config into the struct the backend uses. +func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextStore) *headlampconfig.HeadlampCFG { + return &headlampconfig.HeadlampCFG{ + UseInCluster: conf.InCluster, + KubeConfigPath: conf.KubeConfigPath, + SkippedKubeContexts: conf.SkippedKubeContexts, + ListenAddr: conf.ListenAddr, + CacheEnabled: conf.CacheEnabled, + Port: conf.Port, + DevMode: conf.DevMode, + StaticDir: conf.StaticDir, + Insecure: conf.InsecureSsl, + PluginDir: conf.PluginsDir, + EnableHelm: conf.EnableHelm, + EnableDynamicClusters: conf.EnableDynamicClusters, + WatchPluginsChanges: conf.WatchPluginsChanges, + KubeConfigStore: kubeConfigStore, + BaseURL: conf.BaseURL, + ProxyURLs: strings.Split(conf.ProxyURLs, ","), + TLSCertPath: conf.TLSCertPath, + TLSKeyPath: conf.TLSKeyPath, + } +} + +// buildTelemetryConfig collects only the telemetry fields and passes them along. +func buildTelemetryConfig(conf *config.Config) config.Config { + return config.Config{ + ServiceName: conf.ServiceName, + ServiceVersion: conf.ServiceVersion, + TracingEnabled: conf.TracingEnabled, + MetricsEnabled: conf.MetricsEnabled, + JaegerEndpoint: conf.JaegerEndpoint, + OTLPEndpoint: conf.OTLPEndpoint, + UseOTLPHTTP: conf.UseOTLPHTTP, + StdoutTraceEnabled: conf.StdoutTraceEnabled, + SamplingRate: conf.SamplingRate, + } +} + func createHeadlampConfig(conf *config.Config) *HeadlampConfig { cache := cache.New[interface{}]() kubeConfigStore := kubeconfig.NewContextStore() multiplexer := NewMultiplexer(kubeConfigStore) headlampConfig := &HeadlampConfig{ - HeadlampCFG: &headlampconfig.HeadlampCFG{ - UseInCluster: conf.InCluster, - KubeConfigPath: conf.KubeConfigPath, - SkippedKubeContexts: conf.SkippedKubeContexts, - ListenAddr: conf.ListenAddr, - CacheEnabled: conf.CacheEnabled, - Port: conf.Port, - DevMode: conf.DevMode, - StaticDir: conf.StaticDir, - Insecure: conf.InsecureSsl, - PluginDir: conf.PluginsDir, - EnableHelm: conf.EnableHelm, - EnableDynamicClusters: conf.EnableDynamicClusters, - WatchPluginsChanges: conf.WatchPluginsChanges, - KubeConfigStore: kubeConfigStore, - BaseURL: conf.BaseURL, - ProxyURLs: strings.Split(conf.ProxyURLs, ","), - TLSCertPath: conf.TLSCertPath, - TLSKeyPath: conf.TLSKeyPath, - }, + HeadlampCFG: buildHeadlampCFG(conf, kubeConfigStore), oidcClientID: conf.OidcClientID, oidcValidatorClientID: conf.OidcValidatorClientID, oidcClientSecret: conf.OidcClientSecret, @@ -96,19 +116,14 @@ func createHeadlampConfig(conf *config.Config) *HeadlampConfig { oidcScopes: strings.Split(conf.OidcScopes, ","), oidcSkipTLSVerify: conf.OidcSkipTLSVerify, oidcUseAccessToken: conf.OidcUseAccessToken, + oidcUsePKCE: conf.OidcUsePKCE, + meUsernamePaths: conf.MeUsernamePath, + meEmailPaths: conf.MeEmailPath, + meGroupsPaths: conf.MeGroupsPath, + meUserInfoURL: conf.MeUserInfoURL, cache: cache, multiplexer: multiplexer, - telemetryConfig: config.Config{ - ServiceName: conf.ServiceName, - ServiceVersion: conf.ServiceVersion, - TracingEnabled: conf.TracingEnabled, - MetricsEnabled: conf.MetricsEnabled, - JaegerEndpoint: conf.JaegerEndpoint, - OTLPEndpoint: conf.OTLPEndpoint, - UseOTLPHTTP: conf.UseOTLPHTTP, - StdoutTraceEnabled: conf.StdoutTraceEnabled, - SamplingRate: conf.SamplingRate, - }, + telemetryConfig: buildTelemetryConfig(conf), } if conf.OidcCAFile != "" { diff --git a/backend/go.mod b/backend/go.mod index 6486d0eed2d..22d05b7a4b2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -30,6 +30,7 @@ require ( require ( github.com/coreos/go-oidc/v3 v3.11.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/jmespath/go-jmespath v0.4.0 github.com/prometheus/client_golang v1.22.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/otel v1.35.0 diff --git a/backend/go.sum b/backend/go.sum index 3e04a1f03fc..854525104bd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -290,6 +290,7 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= diff --git a/backend/pkg/auth/auth.go b/backend/pkg/auth/auth.go index d8da4c63da3..2691cf9aa29 100644 --- a/backend/pkg/auth/auth.go +++ b/backend/pkg/auth/auth.go @@ -30,7 +30,10 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/mux" + "github.com/jmespath/go-jmespath" "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" + cfg "github.com/kubernetes-sigs/headlamp/backend/pkg/config" "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" "golang.org/x/oauth2" @@ -271,3 +274,216 @@ func RefreshAndCacheNewToken(ctx context.Context, oidcAuthConfig *kubeconfig.Oid return newToken, nil } + +type MeHandlerOptions struct { + // UsernamePaths is a list of JMESPath expressions to resolve the username claim. + UsernamePaths string + // EmailPaths is a list of JMESPath expressions to resolve the email claim. + EmailPaths string + // GroupsPaths is a list of JMESPath expressions to resolve group memberships. + GroupsPaths string + // UserInfoURL is the URL to fetch additional user info for the /me endpoint. + UserInfoURL string +} + +// HandleMe returns a handler that reads the per-cluster auth cookie and responds with user info. +func HandleMe(opts MeHandlerOptions) http.HandlerFunc { + usernamePaths, emailPaths, groupsPaths, userInfoURL := cfg.ApplyMeDefaults( + opts.UsernamePaths, + opts.EmailPaths, + opts.GroupsPaths, + opts.UserInfoURL, + ) + compiledUsernamePaths := compileJMESPaths(usernamePaths) + compiledEmailPaths := compileJMESPaths(emailPaths) + compiledGroupsPaths := compileJMESPaths(groupsPaths) + + return func(w http.ResponseWriter, r *http.Request) { + clusterName := mux.Vars(r)["clusterName"] + if clusterName == "" { + writeMeJSON(w, http.StatusBadRequest, map[string]interface{}{"message": "cluster not specified"}) + return + } + + requestCluster, token := ParseClusterAndToken(r) + + if requestCluster == "" { + requestCluster = clusterName + } + + if requestCluster != clusterName { + writeMeJSON(w, http.StatusBadRequest, map[string]interface{}{"message": "cluster mismatch"}) + return + } + + if token == "" { + writeMeJSON(w, http.StatusUnauthorized, map[string]interface{}{"message": "unauthorized"}) + return + } + + claims, status, errMsg := parseClaimsFromToken(token) + if status != 0 { + writeMeJSON(w, status, map[string]interface{}{"message": errMsg}) + return + } + + if expiry, err := GetExpiryUnixTimeUTC(claims); err != nil || time.Now().After(expiry) { + writeMeJSON(w, http.StatusUnauthorized, map[string]interface{}{"message": "token expired"}) + return + } + + username := stringValueFromJMESPaths(claims, compiledUsernamePaths) + email := stringValueFromJMESPaths(claims, compiledEmailPaths) + groups := stringSliceFromJMESPaths(claims, compiledGroupsPaths) + + writeMeResponse(w, username, email, groups, userInfoURL) + } +} + +// parseClaimsFromToken extracts the JWT claims from a token. +func parseClaimsFromToken(token string) (map[string]interface{}, int, string) { + parts := strings.SplitN(token, ".", 3) + if len(parts) != 3 || parts[1] == "" { + return nil, http.StatusUnauthorized, "invalid token" + } + + claims, err := DecodeBase64JSON(parts[1]) + if err != nil { + return nil, http.StatusUnauthorized, "invalid token claims" + } + + return claims, 0, "" +} + +// writeMeResponse serializes the identity payload with the standard cache-busting headers. +func writeMeResponse(w http.ResponseWriter, username, email string, groups []string, userInfoURL string) { + writeMeJSON(w, http.StatusOK, map[string]interface{}{ + "username": username, + "email": email, + "groups": groups, + "userInfoURL": userInfoURL, + }) +} + +// writeMeJSON sets the standard cache-control headers used by /me responses and writes the JSON payload. +func writeMeJSON(w http.ResponseWriter, status int, payload map[string]interface{}) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + w.Header().Set("Vary", "Cookie") + w.Header().Del("ETag") + + w.WriteHeader(status) + + if err := json.NewEncoder(w).Encode(payload); err != nil { + logger.Log(logger.LevelError, nil, err, "failed to encode me response") + } +} + +// stringValueFromJMESPaths iterates pre-compiled JMESPath expressions and returns the first string result. +func stringValueFromJMESPaths(payload map[string]interface{}, paths []*jmespath.JMESPath) string { + for _, expr := range paths { + res, err := expr.Search(payload) + if err != nil || res == nil { + continue + } + + switch v := res.(type) { + case string: + if v != "" { + return v + } + case fmt.Stringer: + vs := v.String() + if vs != "" { + return vs + } + case float64: + return fmt.Sprintf("%v", v) + case int64: + return fmt.Sprintf("%v", v) + case map[string]interface{}: + if encoded, ok := marshalToString(v); ok && encoded != "" { + return encoded + } + } + } + + return "" +} + +// stringSliceFromJMESPaths iterates pre-compiled JMESPath expressions and returns the first []string result. +func stringSliceFromJMESPaths(payload map[string]interface{}, paths []*jmespath.JMESPath) []string { + for _, expr := range paths { + res, err := expr.Search(payload) + if err != nil || res == nil { + continue + } + + switch v := res.(type) { + case []interface{}: + out := make([]string, 0, len(v)) + + for _, it := range v { + switch s := it.(type) { + case string: + out = append(out, s) + case float64: + out = append(out, fmt.Sprintf("%v", s)) + case int64: + out = append(out, fmt.Sprintf("%v", s)) + default: + if encoded, ok := marshalToString(it); ok && encoded != "" { + out = append(out, encoded) + } + } + } + + return out + case []string: + return v + } + } + + return []string{} +} + +// compileJMESPaths parses and compiles a list of JMESPath expressions once. +func compileJMESPaths(pathCSV string) []*jmespath.JMESPath { + if strings.TrimSpace(pathCSV) == "" { + return []*jmespath.JMESPath{} + } + + rawPaths := strings.Split(pathCSV, ",") + compiled := make([]*jmespath.JMESPath, 0, len(rawPaths)) + + for _, raw := range rawPaths { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + expr, err := jmespath.Compile(raw) + if err != nil { + logger.Log(logger.LevelWarn, map[string]string{"jmespath": raw}, err, + "failed to compile JMESPath expression, skipping") + continue + } + + compiled = append(compiled, expr) + } + + return compiled +} + +// marshalToString encodes the provided value as JSON and logs failures. +func marshalToString(val interface{}) (string, bool) { + b, err := json.Marshal(val) + if err != nil { + logger.Log(logger.LevelWarn, nil, err, "failed to marshal value to JSON string") + return "", false + } + + return string(b), true +} diff --git a/backend/pkg/auth/auth_test.go b/backend/pkg/auth/auth_test.go index 85e0e856199..f165863e05b 100644 --- a/backend/pkg/auth/auth_test.go +++ b/backend/pkg/auth/auth_test.go @@ -23,6 +23,7 @@ import ( "encoding/json" "encoding/pem" "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -31,6 +32,7 @@ import ( "testing" "time" + "github.com/gorilla/mux" "github.com/kubernetes-sigs/headlamp/backend/pkg/auth" "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" @@ -852,3 +854,174 @@ func TestConfigureTLSContext_CACert(t *testing.T) { require.NoError(t, err) assert.True(t, caCertParsed.IsCA, "Generated certificate should be a CA certificate") } + +func makeTestToken(t *testing.T, claims map[string]interface{}) string { + // helper to build unsigned JWT-like string for tests + header := map[string]string{"alg": "none", "typ": "JWT"} + headerJSON, err := json.Marshal(header) + require.NoError(t, err) + claimsJSON, err := json.Marshal(claims) + require.NoError(t, err) + + return fmt.Sprintf("%s.%s.signature", + base64.RawURLEncoding.EncodeToString(headerJSON), + base64.RawURLEncoding.EncodeToString(claimsJSON), + ) +} + +func TestHandleMe_Success(t *testing.T) { + t.Parallel() + + expiry := time.Now().Add(time.Hour).Unix() + claims := map[string]interface{}{ + "preferred_username": "alice", + "email": "alice@example.com", + "groups": []string{"dev", "ops"}, + "exp": float64(expiry), + } + + token := makeTestToken(t, claims) + + req := httptest.NewRequest(http.MethodGet, "/clusters/test/me", nil) + req = mux.SetURLVars(req, map[string]string{"clusterName": "test"}) + req.AddCookie(&http.Cookie{ + Name: fmt.Sprintf("headlamp-auth-%s.0", auth.SanitizeClusterName("test")), + Value: token, + }) + + rr := httptest.NewRecorder() + + handler := auth.HandleMe(auth.MeHandlerOptions{ + UsernamePaths: "preferred_username", + EmailPaths: "email", + GroupsPaths: "groups", + }) + + handler(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + var got struct { + Username string `json:"username"` + Email string `json:"email"` + Groups []string `json:"groups"` + } + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) + + assert.Equal(t, "alice", got.Username) + assert.Equal(t, "alice@example.com", got.Email) + assert.Equal(t, []string{"dev", "ops"}, got.Groups) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + assert.Equal(t, "no-store, no-cache, must-revalidate, private", rr.Header().Get("Cache-Control")) + assert.Equal(t, "Cookie", rr.Header().Get("Vary")) +} + +func TestHandleMe_HeaderToken(t *testing.T) { + t.Parallel() + + expiry := time.Now().Add(time.Hour).Unix() + claims := map[string]interface{}{ + "preferred_username": "alice", + "email": "alice@example.com", + "groups": []string{"dev", "ops"}, + "exp": float64(expiry), + } + + token := makeTestToken(t, claims) + + req := httptest.NewRequest(http.MethodGet, "/clusters/test/me", nil) + req = mux.SetURLVars(req, map[string]string{"clusterName": "test"}) + req.Header.Set("Authorization", "Bearer "+token) + + rr := httptest.NewRecorder() + + handler := auth.HandleMe(auth.MeHandlerOptions{ + UsernamePaths: "preferred_username", + EmailPaths: "email", + GroupsPaths: "groups", + }) + + handler(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + var got struct { + Username string `json:"username"` + Email string `json:"email"` + Groups []string `json:"groups"` + } + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) + assert.Equal(t, "alice", got.Username) + assert.Equal(t, "alice@example.com", got.Email) + assert.Equal(t, []string{"dev", "ops"}, got.Groups) +} + +func TestHandleMe_ExpiredToken(t *testing.T) { + t.Parallel() + + expiry := time.Now().Add(-time.Hour).Unix() + claims := map[string]interface{}{ + "preferred_username": "alice", + "email": "alice@example.com", + "groups": []string{"dev", "ops"}, + "exp": float64(expiry), + } + + token := makeTestToken(t, claims) + + req := httptest.NewRequest(http.MethodGet, "/clusters/test/me", nil) + req = mux.SetURLVars(req, map[string]string{"clusterName": "test"}) + req.AddCookie(&http.Cookie{ + Name: fmt.Sprintf("headlamp-auth-%s.0", auth.SanitizeClusterName("test")), + Value: token, + }) + + rr := httptest.NewRecorder() + + handler := auth.HandleMe(auth.MeHandlerOptions{ + UsernamePaths: "preferred_username", + EmailPaths: "email", + GroupsPaths: "groups", + }) + + handler(rr, req) + + require.Equal(t, http.StatusUnauthorized, rr.Code) + + var got struct { + Message string `json:"message"` + } + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) + assert.Equal(t, "token expired", got.Message) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + assert.Equal(t, "no-store, no-cache, must-revalidate, private", rr.Header().Get("Cache-Control")) + assert.Equal(t, "Cookie", rr.Header().Get("Vary")) +} + +func TestHandleMe_MissingCookie(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/clusters/test/me", nil) + req = mux.SetURLVars(req, map[string]string{"clusterName": "test"}) + + rr := httptest.NewRecorder() + + handler := auth.HandleMe(auth.MeHandlerOptions{}) + + handler(rr, req) + + require.Equal(t, http.StatusUnauthorized, rr.Code) + + var got struct { + Message string `json:"message"` + } + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) + assert.Equal(t, "unauthorized", got.Message) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + assert.Equal(t, "no-store, no-cache, must-revalidate, private", rr.Header().Get("Cache-Control")) + assert.Equal(t, "Cookie", rr.Header().Get("Vary")) +} diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 76c3c8f00d6..a7092101349 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -20,6 +20,13 @@ import ( const defaultPort = 4466 +const ( + DefaultMeUsernamePath = "preferred_username,upn,username,name" + DefaultMeEmailPath = "email" + DefaultMeGroupsPath = "groups,realm_access.roles" + DefaultMeUserInfoURL = "" +) + type Config struct { Version bool `koanf:"version"` InCluster bool `koanf:"in-cluster"` @@ -47,6 +54,11 @@ type Config struct { OidcUseAccessToken bool `koanf:"oidc-use-access-token"` OidcSkipTLSVerify bool `koanf:"oidc-skip-tls-verify"` OidcCAFile string `koanf:"oidc-ca-file"` + MeUsernamePath string `koanf:"me-username-path"` + MeEmailPath string `koanf:"me-email-path"` + MeGroupsPath string `koanf:"me-groups-path"` + MeUserInfoURL string `koanf:"me-user-info-url"` + OidcUsePKCE bool `koanf:"oidc-use-pkce"` // telemetry configs ServiceName string `koanf:"service-name"` ServiceVersion *string `koanf:"service-version"` @@ -219,6 +231,41 @@ func setKubeConfigPath(config *Config) { } } +// ApplyMeDefaults trims and applies defaults to the JMESPath expressions used for the /me endpoint. +func ApplyMeDefaults(usernamePath, emailPath, groupsPath, userInfoURL string) (string, string, string, string) { + username := strings.TrimSpace(usernamePath) + if username == "" { + username = DefaultMeUsernamePath + } + + email := strings.TrimSpace(emailPath) + if email == "" { + email = DefaultMeEmailPath + } + + groups := strings.TrimSpace(groupsPath) + if groups == "" { + groups = DefaultMeGroupsPath + } + + userInfo := strings.TrimSpace(userInfoURL) + if userInfo == "" { + userInfo = DefaultMeUserInfoURL + } + + return username, email, groups, userInfo +} + +// setMeDefaults ensures the /clusters/{clusterName}/me claim paths fall back to defaults when unset. +func setMeDefaults(config *Config) { + config.MeUsernamePath, config.MeEmailPath, config.MeGroupsPath, config.MeUserInfoURL = ApplyMeDefaults( + config.MeUsernamePath, + config.MeEmailPath, + config.MeGroupsPath, + config.MeUserInfoURL, + ) +} + // Parse Loads the config from flags and env. // env vars should start with HEADLAMP_CONFIG_ and use _ as separator // If a value is set both in flags and env then flag takes priority. @@ -267,6 +314,7 @@ func Parse(args []string) (*Config, error) { // 7. Post-process: patch plugin flag and kubeconfig path. patchWatchPluginsChanges(&config, explicitFlags) setKubeConfigPath(&config) + setMeDefaults(&config) // 8. Validate parsed config. if err := config.Validate(); err != nil { @@ -320,6 +368,15 @@ func DefaultHeadlampKubeConfigFile() (string, error) { func flagset() *flag.FlagSet { f := flag.NewFlagSet("config", flag.ContinueOnError) + addGeneralFlags(f) + addOIDCFlags(f) + addTelemetryFlags(f) + addTLSFlags(f) + + return f +} + +func addGeneralFlags(f *flag.FlagSet) { f.Bool("version", false, "Print version information and exit") f.Bool("in-cluster", false, "Set when running from a k8s cluster") f.Bool("dev", false, "Allow connections from other origins") @@ -337,18 +394,32 @@ func flagset() *flag.FlagSet { f.String("listen-addr", "", "Address to listen on; default is empty, which means listening to any address") f.Uint("port", defaultPort, "Port to listen from") f.String("proxy-urls", "", "Allow proxy requests to specified URLs") + f.Bool("enable-helm", false, "Enable Helm operations") +} +func addOIDCFlags(f *flag.FlagSet) { f.String("oidc-client-id", "", "ClientID for OIDC") f.String("oidc-client-secret", "", "ClientSecret for OIDC") f.String("oidc-validator-client-id", "", "Override ClientID for OIDC during validation") f.String("oidc-idp-issuer-url", "", "Identity provider issuer URL for OIDC") f.String("oidc-callback-url", "", "Callback URL for OIDC") f.String("oidc-validator-idp-issuer-url", "", "Override Identity provider issuer URL for OIDC during validation") - f.String("oidc-scopes", "profile,email", - "A comma separated list of scopes needed from the OIDC provider") + f.String("oidc-scopes", "profile,email", "A comma separated list of scopes needed from the OIDC provider") f.Bool("oidc-skip-tls-verify", false, "Skip TLS verification for OIDC") f.String("oidc-ca-file", "", "CA file for OIDC") f.Bool("oidc-use-access-token", false, "Setup oidc to pass through the access_token instead of the default id_token") + f.Bool("oidc-use-pkce", false, "Use PKCE (Proof Key for Code Exchange) for enhanced security in OIDC flow") + f.String("me-username-path", DefaultMeUsernamePath, + "Comma separated JMESPath expressions used to read username from the JWT payload") + f.String("me-email-path", DefaultMeEmailPath, + "Comma separated JMESPath expressions used to read email from the JWT payload") + f.String("me-groups-path", DefaultMeGroupsPath, + "Comma separated JMESPath expressions used to read groups from the JWT payload") + f.String("me-user-info-url", DefaultMeUserInfoURL, + "URL to fetch additional user info for the /me endpoint. For oauth2proxy /oauth2/userinfo can be used.") +} + +func addTelemetryFlags(f *flag.FlagSet) { // Telemetry flags. f.String("service-name", "headlamp", "Service name for telemetry") f.String("service-version", "0.30.0", "Service version for telemetry") @@ -358,12 +429,12 @@ func flagset() *flag.FlagSet { f.Bool("use-otlp-http", false, "Use HTTP instead of gRPC for OTLP export") f.Bool("stdout-trace-enabled", false, "Enable tracing output to stdout") f.Float64("sampling-rate", 1.0, "Sampling rate for traces") +} + +func addTLSFlags(f *flag.FlagSet) { // TLS flags f.String("tls-cert-path", "", "Certificate for serving TLS") f.String("tls-key-path", "", "Key for serving TLS") - f.Bool("enable-helm", false, "Enable Helm operations") - - return f } // Gets the default plugins-dir depending on platform. diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go index 9059482a23a..8ff162bba58 100644 --- a/backend/pkg/config/config_test.go +++ b/backend/pkg/config/config_test.go @@ -41,6 +41,9 @@ func TestParseBasic(t *testing.T) { assert.Equal(t, "", conf.ListenAddr) assert.Equal(t, uint(4466), conf.Port) assert.Equal(t, "profile,email", conf.OidcScopes) + assert.Equal(t, config.DefaultMeUsernamePath, conf.MeUsernamePath) + assert.Equal(t, config.DefaultMeEmailPath, conf.MeEmailPath) + assert.Equal(t, config.DefaultMeGroupsPath, conf.MeGroupsPath) }, }, { @@ -48,6 +51,7 @@ func TestParseBasic(t *testing.T) { args: []string{"go run ./cmd", "--port=3456"}, verify: func(t *testing.T, conf *config.Config) { assert.Equal(t, uint(3456), conf.Port) + assert.Equal(t, config.DefaultMeUsernamePath, conf.MeUsernamePath) }, }, } @@ -63,47 +67,63 @@ func TestParseBasic(t *testing.T) { } } -func TestParseWithEnv(t *testing.T) { - tests := []struct { - name string - args []string - env map[string]string - verify func(*testing.T, *config.Config) - }{ - { - name: "from_env", - args: []string{"go run ./cmd", "-in-cluster"}, - env: map[string]string{ - "HEADLAMP_CONFIG_OIDC_CLIENT_SECRET": "superSecretBotsStayAwayPlease", - }, - verify: func(t *testing.T, conf *config.Config) { - assert.Equal(t, "superSecretBotsStayAwayPlease", conf.OidcClientSecret) - }, +var ParseWithEnvTests = []struct { + name string + args []string + env map[string]string + verify func(*testing.T, *config.Config) +}{ + { + name: "from_env", + args: []string{"go run ./cmd", "-in-cluster"}, + env: map[string]string{ + "HEADLAMP_CONFIG_OIDC_CLIENT_SECRET": "superSecretBotsStayAwayPlease", }, - { - name: "both_args_and_env", - args: []string{"go run ./cmd", "--port=9876"}, - env: map[string]string{ - "HEADLAMP_CONFIG_PORT": "1234", - }, - verify: func(t *testing.T, conf *config.Config) { - assert.NotEqual(t, uint(1234), conf.Port) - assert.Equal(t, uint(9876), conf.Port) - }, + verify: func(t *testing.T, conf *config.Config) { + assert.Equal(t, "superSecretBotsStayAwayPlease", conf.OidcClientSecret) }, - { - name: "kubeconfig_from_default_env", - args: []string{"go run ./cmd"}, - env: map[string]string{ - "KUBECONFIG": "~/.kube/test_config.yaml", - }, - verify: func(t *testing.T, conf *config.Config) { - assert.Equal(t, "~/.kube/test_config.yaml", conf.KubeConfigPath) - }, + }, + { + name: "both_args_and_env", + args: []string{"go run ./cmd", "--port=9876"}, + env: map[string]string{ + "HEADLAMP_CONFIG_PORT": "1234", }, - } + verify: func(t *testing.T, conf *config.Config) { + assert.NotEqual(t, uint(1234), conf.Port) + assert.Equal(t, uint(9876), conf.Port) + }, + }, + { + name: "me_paths", + args: []string{"go run ./cmd"}, + env: map[string]string{ + "HEADLAMP_CONFIG_ME_USERNAME_PATH": "user.name", + "HEADLAMP_CONFIG_ME_EMAIL_PATH": "user.email", + "HEADLAMP_CONFIG_ME_GROUPS_PATH": "user.groups", + "HEADLAMP_CONFIG_ME_USER_INFO_URL": "/oauth2/userinfo", + }, + verify: func(t *testing.T, conf *config.Config) { + assert.Equal(t, "user.name", conf.MeUsernamePath) + assert.Equal(t, "user.email", conf.MeEmailPath) + assert.Equal(t, "user.groups", conf.MeGroupsPath) + assert.Equal(t, "/oauth2/userinfo", conf.MeUserInfoURL) + }, + }, + { + name: "kubeconfig_from_default_env", + args: []string{"go run ./cmd"}, + env: map[string]string{ + "KUBECONFIG": "~/.kube/test_config.yaml", + }, + verify: func(t *testing.T, conf *config.Config) { + assert.Equal(t, "~/.kube/test_config.yaml", conf.KubeConfigPath) + }, + }, +} - for _, tt := range tests { +func TestParseWithEnv(t *testing.T) { + for _, tt := range ParseWithEnvTests { t.Run(tt.name, func(t *testing.T) { for key, value := range tt.env { os.Setenv(key, value) diff --git a/backend/pkg/kubeconfig/kubeconfig.go b/backend/pkg/kubeconfig/kubeconfig.go index 1960db1e799..4dff110abf1 100644 --- a/backend/pkg/kubeconfig/kubeconfig.go +++ b/backend/pkg/kubeconfig/kubeconfig.go @@ -975,7 +975,8 @@ func GetInClusterContext(oidcIssuerURL string, var oidcConf *OidcConfig - if oidcClientID != "" && oidcClientSecret != "" && oidcIssuerURL != "" && oidcScopes != "" { + if oidcClientID != "" && oidcIssuerURL != "" && oidcScopes != "" { + // client secret is optional for in-cluster OIDC configuration oidcConf = &OidcConfig{ ClientID: oidcClientID, ClientSecret: oidcClientSecret, diff --git a/backend/pkg/serviceproxy/connection.go b/backend/pkg/serviceproxy/connection.go index 834dbe08c4f..292be596119 100644 --- a/backend/pkg/serviceproxy/connection.go +++ b/backend/pkg/serviceproxy/connection.go @@ -35,6 +35,10 @@ func (c *Connection) Get(requestURI string) ([]byte, error) { return nil, fmt.Errorf("invalid request uri: %w", err) } + if rel.IsAbs() { + return nil, fmt.Errorf("request uri must be a relative path") + } + fullURL := base.ResolveReference(rel) body, err := HTTPGet(context.Background(), fullURL.String()) diff --git a/backend/pkg/serviceproxy/connection_test.go b/backend/pkg/serviceproxy/connection_test.go index 3bdb1e6bcb9..71d4490cae6 100644 --- a/backend/pkg/serviceproxy/connection_test.go +++ b/backend/pkg/serviceproxy/connection_test.go @@ -41,38 +41,45 @@ func TestNewConnection(t *testing.T) { } } -func TestGet(t *testing.T) { - tests := []struct { - name string - uri string - requestURI string - wantBody []byte - wantErr bool - }{ - { - name: "valid request", - uri: "http://example.com", - requestURI: "/test", - wantBody: []byte("Hello, World!"), - wantErr: false, - }, - { - name: "invalid URI", - uri: " invalid-uri", - requestURI: "/test", - wantBody: nil, - wantErr: true, - }, - { - name: "invalid request URI", - uri: "http://example.com", - requestURI: " invalid-request-uri", - wantBody: nil, - wantErr: true, - }, - } +var getTests = []struct { + name string + uri string + requestURI string + wantBody []byte + wantErr bool +}{ + { + name: "valid request", + uri: "http://example.com", + requestURI: "/test", + wantBody: []byte("Hello, World!"), + wantErr: false, + }, + { + name: "invalid URI", + uri: " invalid-uri", + requestURI: "/test", + wantBody: nil, + wantErr: true, + }, + { + name: "invalid request URI", + uri: "http://example.com", + requestURI: " invalid-request-uri", + wantBody: nil, + wantErr: true, + }, + { + name: "absolute request URI rejected", + uri: "http://example.com", + requestURI: "http://malicious.local", + wantBody: nil, + wantErr: true, + }, +} - for _, tt := range tests { +func TestGet(t *testing.T) { + for _, tt := range getTests { t.Run(tt.name, func(t *testing.T) { conn := &Connection{URI: tt.uri} diff --git a/charts/headlamp/Chart.yaml b/charts/headlamp/Chart.yaml index 4f67c132b07..fbc369e49c6 100644 --- a/charts/headlamp/Chart.yaml +++ b/charts/headlamp/Chart.yaml @@ -20,8 +20,8 @@ sources: maintainers: - name: kinvolk url: https://kinvolk.io/ -version: 0.36.0 -appVersion: 0.36.0 +version: 0.37.0 +appVersion: 0.37.0 annotations: artifacthub.io/signKey: | fingerprint: 2956B7F7167769370C93730C7264DA7B85D08A37 diff --git a/charts/headlamp/README.md b/charts/headlamp/README.md index 26b2fba7efe..f837b817b9f 100644 --- a/charts/headlamp/README.md +++ b/charts/headlamp/README.md @@ -85,10 +85,12 @@ $ helm install my-headlamp headlamp/headlamp \ | config.oidc.clientSecret | string | `""` | OIDC client secret | | config.oidc.issuerURL | string | `""` | OIDC issuer URL | | config.oidc.scopes | string | `""` | OIDC scopes to be used | +| config.oidc.usePKCE | bool | `false` | Use PKCE (Proof Key for Code Exchange) for enhanced security in OIDC flow | | config.oidc.secret.create | bool | `true` | Create OIDC secret using provided values | | config.oidc.secret.name | string | `"oidc"` | Name of the OIDC secret | | config.oidc.externalSecret.enabled | bool | `false` | Enable using external secret for OIDC | | config.oidc.externalSecret.name | string | `""` | Name of external OIDC secret | +| config.oidc.meUserInfoURL | string | `""` | URL to fetch additional user info for the /me endpoint. For oauth2proxy /oauth2/userinfo can be used. | There are three ways to configure OIDC: diff --git a/charts/headlamp/templates/deployment.yaml b/charts/headlamp/templates/deployment.yaml index 772dce8fb42..81d91fa8f2c 100644 --- a/charts/headlamp/templates/deployment.yaml +++ b/charts/headlamp/templates/deployment.yaml @@ -8,7 +8,9 @@ {{- $callbackURL := "" }} {{- $validatorClientID := "" }} {{- $validatorIssuerURL := "" }} +{{- $usePKCE := "" }} {{- $useAccessToken := "" }} +{{- $meUserInfoURL := "" }} # This block of code is used to extract the values from the env. # This is done to check if the values are non-empty and if they are, they are used in the deployment.yaml. @@ -37,6 +39,12 @@ {{- if eq .name "OIDC_USE_ACCESS_TOKEN" }} {{- $useAccessToken = .value | toString }} {{- end }} + {{- if eq .name "OIDC_USE_PKCE" }} + {{- $usePKCE = .value | toString }} + {{- end }} + {{- if eq .name "ME_USER_INFO_URL" }} + {{- $meUserInfoURL = .value | toString }} + {{- end }} {{- end }} apiVersion: apps/v1 @@ -159,6 +167,20 @@ spec: name: {{ $oidc.secret.name }} key: useAccessToken {{- end }} + {{- if $oidc.usePKCE }} + - name: OIDC_USE_PKCE + valueFrom: + secretKeyRef: + name: {{ $oidc.secret.name }} + key: usePKCE + {{- end }} + {{- if $oidc.meUserInfoURL }} + - name: ME_USER_INFO_URL + valueFrom: + secretKeyRef: + name: {{ $oidc.secret.name }} + key: meUserInfoURL + {{- end }} {{- else }} {{- if $oidc.clientID }} - name: OIDC_CLIENT_ID @@ -192,6 +214,14 @@ spec: - name: OIDC_USE_ACCESS_TOKEN value: {{ $oidc.useAccessToken }} {{- end }} + {{- if $oidc.usePKCE }} + - name: OIDC_USE_PKCE + value: {{ $oidc.usePKCE }} + {{- end }} + {{- if $oidc.meUserInfoURL }} + - name: ME_USER_INFO_URL + value: {{ $oidc.meUserInfoURL }} + {{- end }} {{- end }} {{- if .Values.env }} {{- toYaml .Values.env | nindent 12 }} @@ -245,6 +275,12 @@ spec: # Check if useAccessToken is non false either from env or oidc.config - "-oidc-use-access-token=$(OIDC_USE_ACCESS_TOKEN)" {{- end }} + {{- if or (eq ($oidc.usePKCE | toString) "true") (eq $usePKCE "true") }} + - "-oidc-use-pkce=$(OIDC_USE_PKCE)" + {{- end }} + {{- if or (ne $oidc.meUserInfoURL "") (ne $meUserInfoURL "") }} + - "-me-user-info-url=$(ME_USER_INFO_URL)" + {{- end }} {{- else }} - "-oidc-client-id=$(OIDC_CLIENT_ID)" - "-oidc-client-secret=$(OIDC_CLIENT_SECRET)" @@ -262,6 +298,9 @@ spec: # Check if validatorIssuerURL is non empty either from env or oidc.config - "-oidc-validator-idp-issuer-url=$(OIDC_VALIDATOR_ISSUER_URL)" {{- end }} + {{- if or (ne $oidc.meUserInfoURL "") (ne $meUserInfoURL "") }} + - "-me-user-info-url=$(ME_USER_INFO_URL)" + {{- end }} {{- end }} {{- with .Values.config.baseURL }} - "-base-url={{ . }}" @@ -309,13 +348,15 @@ spec: {{- end }} args: - | - echo "Installing headlamp-plugin globally..." - npm install -g @headlamp-k8s/pluginctl@{{ .Values.pluginsManager.version }} - echo "Installed headlamp-plugin successfully." if [ -f "/config/plugin.yml" ]; then echo "Installing plugins from config..." cat /config/plugin.yml - pluginctl install --config /config/plugin.yml --folderName {{ .Values.config.pluginsDir }} --watch + # Use a writable cache directory + export NPM_CONFIG_CACHE=/tmp/npm-cache + # Use a writable config directory + export NPM_CONFIG_USERCONFIG=/tmp/npm-userconfig + mkdir -p /tmp/npm-cache /tmp/npm-userconfig + npx --yes @headlamp-k8s/pluginctl@{{ .Values.pluginsManager.version }} install --config /config/plugin.yml --folderName {{ .Values.config.pluginsDir }} --watch fi volumeMounts: - name: plugins-dir diff --git a/charts/headlamp/templates/plugin-configmap.yaml b/charts/headlamp/templates/plugin-configmap.yaml index 5d5619fb4d1..43bf007bde7 100644 --- a/charts/headlamp/templates/plugin-configmap.yaml +++ b/charts/headlamp/templates/plugin-configmap.yaml @@ -1,4 +1,4 @@ -{{- if .Values.pluginsManager.enabled }} +{{- if .Values.pluginsManager.enabled -}} apiVersion: v1 kind: ConfigMap metadata: @@ -7,6 +7,5 @@ metadata: labels: {{- include "headlamp.labels" . | nindent 4 }} data: - plugin.yml: | - {{ .Values.pluginsManager.configContent | nindent 4 }} + plugin.yml: |{{ .Values.pluginsManager.configContent | nindent 4 }} {{- end }} diff --git a/charts/headlamp/templates/secret.yaml b/charts/headlamp/templates/secret.yaml index 1af93d96fed..afda05afa01 100644 --- a/charts/headlamp/templates/secret.yaml +++ b/charts/headlamp/templates/secret.yaml @@ -31,5 +31,8 @@ data: {{- with .useAccessToken }} useAccessToken: {{ . | toString | b64enc | quote }} {{- end }} +{{- with .usePKCE }} + usePKCE: {{ . | toString | b64enc | quote }} +{{- end }} {{- end }} {{- end }} diff --git a/charts/headlamp/templates/service.yaml b/charts/headlamp/templates/service.yaml index 1a80c333d1b..16a1dd82ec5 100644 --- a/charts/headlamp/templates/service.yaml +++ b/charts/headlamp/templates/service.yaml @@ -18,7 +18,7 @@ spec: externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy | quote }} {{- end }} {{ if (and (eq .Values.service.type "LoadBalancer") (not (empty .Values.service.loadBalancerSourceRanges))) }} - loadBalancerSourceRanges: {{ .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: {{ .Values.service.loadBalancerSourceRanges | toYaml | nindent 2 }} {{ end }} {{- if (and (eq .Values.service.type "LoadBalancer") (not (empty .Values.service.loadBalancerIP))) }} loadBalancerIP: {{ .Values.service.loadBalancerIP }} diff --git a/charts/headlamp/tests/expected_templates/azure-oidc-with-validators.yaml b/charts/headlamp/tests/expected_templates/azure-oidc-with-validators.yaml index 0a5436a766e..ca2e9474ef1 100644 --- a/charts/headlamp/tests/expected_templates/azure-oidc-with-validators.yaml +++ b/charts/headlamp/tests/expected_templates/azure-oidc-with-validators.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/clusterrolebinding.yaml @@ -18,10 +18,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -39,10 +39,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -66,10 +66,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -94,7 +94,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/default.yaml b/charts/headlamp/tests/expected_templates/default.yaml index 181ab0a31e2..c55a00079d9 100644 --- a/charts/headlamp/tests/expected_templates/default.yaml +++ b/charts/headlamp/tests/expected_templates/default.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -27,10 +27,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -48,10 +48,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -75,10 +75,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -103,7 +103,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/extra-args.yaml b/charts/headlamp/tests/expected_templates/extra-args.yaml index 000cfe891d1..232c40a31c2 100644 --- a/charts/headlamp/tests/expected_templates/extra-args.yaml +++ b/charts/headlamp/tests/expected_templates/extra-args.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -27,10 +27,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -48,10 +48,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -75,10 +75,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -103,7 +103,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/extra-manifests.yaml b/charts/headlamp/tests/expected_templates/extra-manifests.yaml index 348aae4e04b..54caf8fdb06 100644 --- a/charts/headlamp/tests/expected_templates/extra-manifests.yaml +++ b/charts/headlamp/tests/expected_templates/extra-manifests.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -44,10 +44,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -65,10 +65,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -92,10 +92,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -120,7 +120,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/me-user-info-url-directly.yaml b/charts/headlamp/tests/expected_templates/me-user-info-url-directly.yaml new file mode 100644 index 00000000000..6fbc748a641 --- /dev/null +++ b/charts/headlamp/tests/expected_templates/me-user-info-url-directly.yaml @@ -0,0 +1,130 @@ +--- +# Source: headlamp/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +--- +# Source: headlamp/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: oidc + namespace: default +type: Opaque +data: +--- +# Source: headlamp/templates/clusterrolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: headlamp-admin + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: headlamp + namespace: default +--- +# Source: headlamp/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +spec: + type: ClusterIP + + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp +--- +# Source: headlamp/templates/deployment.yaml +# This block of code is used to extract the values from the env. +# This is done to check if the values are non-empty and if they are, they are used in the deployment.yaml. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + template: + metadata: + labels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + spec: + serviceAccountName: headlamp + automountServiceAccountToken: true + securityContext: + {} + containers: + - name: headlamp + securityContext: + privileged: false + runAsGroup: 101 + runAsNonRoot: true + runAsUser: 100 + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" + imagePullPolicy: IfNotPresent + + env: + - name: ME_USER_INFO_URL + value: /oauth2/userinfocustom1 + args: + - "-in-cluster" + - "-plugins-dir=/headlamp/plugins" + # Check if externalSecret is disabled + - "-me-user-info-url=$(ME_USER_INFO_URL)" + ports: + - name: http + containerPort: 4466 + protocol: TCP + livenessProbe: + httpGet: + path: "/" + port: http + readinessProbe: + httpGet: + path: "/" + port: http + resources: + {} diff --git a/charts/headlamp/tests/expected_templates/me-user-info-url.yaml b/charts/headlamp/tests/expected_templates/me-user-info-url.yaml new file mode 100644 index 00000000000..0eb1535488a --- /dev/null +++ b/charts/headlamp/tests/expected_templates/me-user-info-url.yaml @@ -0,0 +1,133 @@ +--- +# Source: headlamp/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +--- +# Source: headlamp/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: oidc + namespace: default +type: Opaque +data: +--- +# Source: headlamp/templates/clusterrolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: headlamp-admin + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: headlamp + namespace: default +--- +# Source: headlamp/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +spec: + type: ClusterIP + + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp +--- +# Source: headlamp/templates/deployment.yaml +# This block of code is used to extract the values from the env. +# This is done to check if the values are non-empty and if they are, they are used in the deployment.yaml. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + template: + metadata: + labels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + spec: + serviceAccountName: headlamp + automountServiceAccountToken: true + securityContext: + {} + containers: + - name: headlamp + securityContext: + privileged: false + runAsGroup: 101 + runAsNonRoot: true + runAsUser: 100 + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" + imagePullPolicy: IfNotPresent + + env: + - name: ME_USER_INFO_URL + valueFrom: + secretKeyRef: + name: oidc + key: meUserInfoURL + args: + - "-in-cluster" + - "-plugins-dir=/headlamp/plugins" + # Check if externalSecret is disabled + - "-me-user-info-url=$(ME_USER_INFO_URL)" + ports: + - name: http + containerPort: 4466 + protocol: TCP + livenessProbe: + httpGet: + path: "/" + port: http + readinessProbe: + httpGet: + path: "/" + port: http + resources: + {} diff --git a/charts/headlamp/tests/expected_templates/namespace-override-oidc-create-secret.yaml b/charts/headlamp/tests/expected_templates/namespace-override-oidc-create-secret.yaml index 6f088f5140b..5707d18dd6a 100644 --- a/charts/headlamp/tests/expected_templates/namespace-override-oidc-create-secret.yaml +++ b/charts/headlamp/tests/expected_templates/namespace-override-oidc-create-secret.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: mynamespace2 labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -31,10 +31,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -52,10 +52,10 @@ metadata: name: headlamp namespace: mynamespace2 labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -79,10 +79,10 @@ metadata: name: headlamp namespace: mynamespace2 labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -107,7 +107,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/namespace-override.yaml b/charts/headlamp/tests/expected_templates/namespace-override.yaml index 6033e353291..ea03730cc63 100644 --- a/charts/headlamp/tests/expected_templates/namespace-override.yaml +++ b/charts/headlamp/tests/expected_templates/namespace-override.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: mynamespace labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -27,10 +27,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -48,10 +48,10 @@ metadata: name: headlamp namespace: mynamespace labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -75,10 +75,10 @@ metadata: name: headlamp namespace: mynamespace labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -103,7 +103,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/non-azure-oidc.yaml b/charts/headlamp/tests/expected_templates/non-azure-oidc.yaml index 3032a1e9e61..25110e14dc1 100644 --- a/charts/headlamp/tests/expected_templates/non-azure-oidc.yaml +++ b/charts/headlamp/tests/expected_templates/non-azure-oidc.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/clusterrolebinding.yaml @@ -18,10 +18,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -39,10 +39,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -66,10 +66,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -94,7 +94,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/oidc-create-secret.yaml b/charts/headlamp/tests/expected_templates/oidc-create-secret.yaml index bff34aeb6ef..9160482da27 100644 --- a/charts/headlamp/tests/expected_templates/oidc-create-secret.yaml +++ b/charts/headlamp/tests/expected_templates/oidc-create-secret.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -31,10 +31,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -52,10 +52,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -79,10 +79,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -107,7 +107,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/oidc-directly-env.yaml b/charts/headlamp/tests/expected_templates/oidc-directly-env.yaml index 3585650a06a..b6a74a28e3f 100644 --- a/charts/headlamp/tests/expected_templates/oidc-directly-env.yaml +++ b/charts/headlamp/tests/expected_templates/oidc-directly-env.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -27,10 +27,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -48,10 +48,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -75,10 +75,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -103,7 +103,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/oidc-directly.yaml b/charts/headlamp/tests/expected_templates/oidc-directly.yaml index 8e55710efb5..4b09dca2458 100644 --- a/charts/headlamp/tests/expected_templates/oidc-directly.yaml +++ b/charts/headlamp/tests/expected_templates/oidc-directly.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/clusterrolebinding.yaml @@ -18,10 +18,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -39,10 +39,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -66,10 +66,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -94,7 +94,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/oidc-external-secret.yaml b/charts/headlamp/tests/expected_templates/oidc-external-secret.yaml index 8046ae39524..f8b72a5bdff 100644 --- a/charts/headlamp/tests/expected_templates/oidc-external-secret.yaml +++ b/charts/headlamp/tests/expected_templates/oidc-external-secret.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/clusterrolebinding.yaml @@ -18,10 +18,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -39,10 +39,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -66,10 +66,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -94,7 +94,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent # Check if externalSecret is enabled diff --git a/charts/headlamp/tests/expected_templates/oidc-pkce.yaml b/charts/headlamp/tests/expected_templates/oidc-pkce.yaml new file mode 100644 index 00000000000..0f5f0d1cf46 --- /dev/null +++ b/charts/headlamp/tests/expected_templates/oidc-pkce.yaml @@ -0,0 +1,137 @@ +--- +# Source: headlamp/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +--- +# Source: headlamp/templates/clusterrolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: headlamp-admin + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: headlamp + namespace: default +--- +# Source: headlamp/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +spec: + type: ClusterIP + + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp +--- +# Source: headlamp/templates/deployment.yaml +# This block of code is used to extract the values from the env. +# This is done to check if the values are non-empty and if they are, they are used in the deployment.yaml. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.37.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.37.0" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + template: + metadata: + labels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + spec: + serviceAccountName: headlamp + automountServiceAccountToken: true + securityContext: + {} + containers: + - name: headlamp + securityContext: + privileged: false + runAsGroup: 101 + runAsNonRoot: true + runAsUser: 100 + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" + imagePullPolicy: IfNotPresent + + env: + - name: OIDC_CLIENT_ID + value: testClientId + - name: OIDC_CLIENT_SECRET + value: testClientSecret + - name: OIDC_ISSUER_URL + value: testIssuerURL + - name: OIDC_SCOPES + value: testScope + - name: OIDC_USE_PKCE + value: true + args: + - "-in-cluster" + - "-plugins-dir=/headlamp/plugins" + # Check if externalSecret is disabled + # Check if clientID is non empty either from env or oidc.config + - "-oidc-client-id=$(OIDC_CLIENT_ID)" + # Check if clientSecret is non empty either from env or oidc.config + - "-oidc-client-secret=$(OIDC_CLIENT_SECRET)" + # Check if issuerURL is non empty either from env or oidc.config + - "-oidc-idp-issuer-url=$(OIDC_ISSUER_URL)" + # Check if scopes are non empty either from env or oidc.config + - "-oidc-scopes=$(OIDC_SCOPES)" + - "-oidc-use-pkce=$(OIDC_USE_PKCE)" + ports: + - name: http + containerPort: 4466 + protocol: TCP + livenessProbe: + httpGet: + path: "/" + port: http + readinessProbe: + httpGet: + path: "/" + port: http + resources: + {} diff --git a/charts/headlamp/tests/expected_templates/oidc-validator-overrides.yaml b/charts/headlamp/tests/expected_templates/oidc-validator-overrides.yaml index 93d36fb53bd..be55526671f 100644 --- a/charts/headlamp/tests/expected_templates/oidc-validator-overrides.yaml +++ b/charts/headlamp/tests/expected_templates/oidc-validator-overrides.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/clusterrolebinding.yaml @@ -18,10 +18,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -39,10 +39,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -66,10 +66,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -94,7 +94,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/pod-disruption.yaml b/charts/headlamp/tests/expected_templates/pod-disruption.yaml index 6176a1be43e..570fce727e2 100644 --- a/charts/headlamp/tests/expected_templates/pod-disruption.yaml +++ b/charts/headlamp/tests/expected_templates/pod-disruption.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: maxUnavailable: 1 @@ -26,10 +26,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -47,10 +47,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -68,10 +68,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -95,10 +95,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -123,7 +123,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/security-context.yaml b/charts/headlamp/tests/expected_templates/security-context.yaml index 18830ca82c0..0cefaa4cae3 100644 --- a/charts/headlamp/tests/expected_templates/security-context.yaml +++ b/charts/headlamp/tests/expected_templates/security-context.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -28,10 +28,10 @@ metadata: name: headlamp-plugin-config namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm data: plugin.yml: | @@ -42,10 +42,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -63,10 +63,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -90,10 +90,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -129,7 +129,7 @@ spec: runAsUser: 100 seccompProfile: type: RuntimeDefault - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: @@ -159,13 +159,15 @@ spec: command: ["/bin/sh", "-c"] args: - | - echo "Installing headlamp-plugin globally..." - npm install -g @headlamp-k8s/pluginctl@1.0.0 - echo "Installed headlamp-plugin successfully." if [ -f "/config/plugin.yml" ]; then echo "Installing plugins from config..." cat /config/plugin.yml - pluginctl install --config /config/plugin.yml --folderName /headlamp/plugins --watch + # Use a writable cache directory + export NPM_CONFIG_CACHE=/tmp/npm-cache + # Use a writable config directory + export NPM_CONFIG_USERCONFIG=/tmp/npm-userconfig + mkdir -p /tmp/npm-cache /tmp/npm-userconfig + npx --yes @headlamp-k8s/pluginctl@1.0.0 install --config /config/plugin.yml --folderName /headlamp/plugins --watch fi volumeMounts: - name: plugins-dir diff --git a/charts/headlamp/tests/expected_templates/tls-added.yaml b/charts/headlamp/tests/expected_templates/tls-added.yaml index 75897e055ec..6bbdbbb0f18 100644 --- a/charts/headlamp/tests/expected_templates/tls-added.yaml +++ b/charts/headlamp/tests/expected_templates/tls-added.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -27,10 +27,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -48,10 +48,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -75,10 +75,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -103,7 +103,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/expected_templates/volumes-added.yaml b/charts/headlamp/tests/expected_templates/volumes-added.yaml index 662717fc1ac..8e47e86cb3e 100644 --- a/charts/headlamp/tests/expected_templates/volumes-added.yaml +++ b/charts/headlamp/tests/expected_templates/volumes-added.yaml @@ -6,10 +6,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm --- # Source: headlamp/templates/secret.yaml @@ -27,10 +27,10 @@ kind: ClusterRoleBinding metadata: name: headlamp-admin labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -48,10 +48,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -75,10 +75,10 @@ metadata: name: headlamp namespace: default labels: - helm.sh/chart: headlamp-0.36.0 + helm.sh/chart: headlamp-0.37.0 app.kubernetes.io/name: headlamp app.kubernetes.io/instance: headlamp - app.kubernetes.io/version: "0.36.0" + app.kubernetes.io/version: "0.37.0" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -103,7 +103,7 @@ spec: runAsGroup: 101 runAsNonRoot: true runAsUser: 100 - image: "ghcr.io/headlamp-k8s/headlamp:v0.36.0" + image: "ghcr.io/headlamp-k8s/headlamp:v0.37.0" imagePullPolicy: IfNotPresent env: diff --git a/charts/headlamp/tests/test_cases/me-user-info-url-directly.yaml b/charts/headlamp/tests/test_cases/me-user-info-url-directly.yaml new file mode 100644 index 00000000000..149928b4816 --- /dev/null +++ b/charts/headlamp/tests/test_cases/me-user-info-url-directly.yaml @@ -0,0 +1,3 @@ +env: + - name: ME_USER_INFO_URL + value: /oauth2/userinfocustom1 diff --git a/charts/headlamp/tests/test_cases/me-user-info-url.yaml b/charts/headlamp/tests/test_cases/me-user-info-url.yaml new file mode 100644 index 00000000000..76c4f640500 --- /dev/null +++ b/charts/headlamp/tests/test_cases/me-user-info-url.yaml @@ -0,0 +1,4 @@ +# -- Headlamp OIDC me user info URL test case +config: + oidc: + meUserInfoURL: /oauth2/userinfocustom2 diff --git a/charts/headlamp/tests/test_cases/oidc-pkce.yaml b/charts/headlamp/tests/test_cases/oidc-pkce.yaml new file mode 100644 index 00000000000..f8a5ed5ec48 --- /dev/null +++ b/charts/headlamp/tests/test_cases/oidc-pkce.yaml @@ -0,0 +1,15 @@ +# This is a test case for the direct OIDC configuration in the Headlamp deployment. +# The oidc.secret.create field is false to avoid creating a secret for OIDC. +# The oidc.clientID field is a string that specifies the client ID for OIDC. +# The oidc.clientSecret field is a string that specifies the client secret for OIDC. +# The oidc.issuerURL field is a string that specifies the issuer URL for OIDC. +# The oidc.scopes field is a string that specifies the scopes for OIDC. +config: + oidc: + secret: + create: false + clientID: "testClientId" + clientSecret: "testClientSecret" + issuerURL: "testIssuerURL" + scopes: "testScope" + usePKCE: true diff --git a/charts/headlamp/values.schema.json b/charts/headlamp/values.schema.json index c3282d045d5..e6e86b531b9 100644 --- a/charts/headlamp/values.schema.json +++ b/charts/headlamp/values.schema.json @@ -207,6 +207,10 @@ "type": "string", "description": "Scopes of the OIDC provider" }, + "usePKCE": { + "type": "boolean", + "description": "Use PKCE (Proof Key for Code Exchange) for enhanced security in OIDC flow" + }, "externalSecret": { "type": "object", "description": "External secret to use for OIDC configuration", diff --git a/charts/headlamp/values.yaml b/charts/headlamp/values.yaml index 4f1281dbe7d..413bed7c475 100644 --- a/charts/headlamp/values.yaml +++ b/charts/headlamp/values.yaml @@ -78,6 +78,8 @@ config: validatorIssuerURL: "" # -- Use 'access_token' instead of 'id_token' when authenticating using OIDC useAccessToken: false + # -- Use PKCE (Proof Key for Code Exchange) for enhanced security in OIDC flow + usePKCE: false # Option 3: # @param config.oidc - External OIDC secret configuration @@ -94,6 +96,11 @@ config: externalSecret: enabled: false name: "" + + # -- URL to fetch additional user info for the /me endpoint. + # For oauth2proxy /oauth2/userinfo can be used. Empty and it will not be used. + meUserInfoURL: "" + # -- directory to look for plugins pluginsDir: "/headlamp/plugins" enableHelm: false diff --git a/docs/contributing.md b/docs/contributing.md index f2e6c395a7f..d3c345d5eb3 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -55,8 +55,8 @@ Follow these steps when submitting a PR to ensure it meets the project’s stand Run the following commands from your project directory: -- `make frontend-test` - Run the test suite -- `make frontend-lint` - Format your code to match the project +- `npm run frontend:test` - Run the test suite +- `npm run frontend:lint` - Format your code to match the project These steps ensure your code is functional, well-typed, and formatted consistently. @@ -156,11 +156,11 @@ For linting the `backend` and `frontend`, use the following commands (respectively): ```bash -make backend-lint +npm run backend:lint ``` ```bash -make frontend-lint +npm run frontend:lint ``` The linters are also run in the CI system, so any PRs you create will be @@ -194,13 +194,13 @@ an associated story when possible. For running the frontend tests, use the following command: ```bash -make frontend-test +npm run frontend:test ``` The backend uses go's testing and can be run by using the following command: ```bash -make backend-test +npm run backend:test ``` Tests will run as part of the CI after a Pull Request is open. diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2e63e6a7c54..3862d3c9c0b 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -128,23 +128,23 @@ The following components are in separate GitHub repos: - **Plugins**: Extensible modules that add custom functionality to the UI. The Headlamp team maintains their plugins in the [headlamp-k8s/plugins repo](https://github.com/headlamp-k8s/plugins). These include plugins for projects like Flux, Backstage and Inspektor Gadget. - **Headlamp Website**: Maintained in the [headlamp-k8s/website repo](https://github.com/headlamp-k8s/website). This contains things like the blog and the documentation. The website can be found at https://headlamp.dev/ -### Makefile task entry point +### npm scripts entry point -The headlamp/ repo [Makefile](https://github.com/kubernetes-sigs/headlamp/blob/main/Makefile) contains targets for building and testing different components. +The headlamp/ repo root [package.json](https://github.com/kubernetes-sigs/headlamp/blob/main/package.json) contains scripts for building and testing different components. Here are some examples: ```shell -make backend -make backend-lint -make backend-test -make run-backend -make frontend -make frontend-lint -make frontend-test -make run-frontend -make app-test -make run-app +npm run backend:build +npm run backend:lint +npm run backend:test +npm run backend:start +npm run frontend:build +npm run frontend:lint +npm run frontend:test +npm run frontend:start +npm run app:test +npm run start:app ``` ### Frontend diff --git a/docs/development/backend.md b/docs/development/backend.md index 0dc214d9375..d4feb0d2387 100644 --- a/docs/development/backend.md +++ b/docs/development/backend.md @@ -18,13 +18,13 @@ redirects the requests to the defined proxies. The backend (Headlamp's server) can be quickly built using: ```bash -make backend +npm run backend:build ``` Once built, it can be run in development mode (insecure / don't use in production) using: ```bash -make run-backend +npm run backend:start ``` ## Lint @@ -32,13 +32,13 @@ make run-backend To lint the backend/ code. ```bash -make backend-lint +npm run backend:lint ``` This command can fix some lint issues. ```bash -make backend-lint-fix +npm run backend:lint:fix ``` ## Format @@ -46,23 +46,23 @@ make backend-lint-fix To format the backend code. ```bash -make backend-format +npm run backend:format ``` ## Test ```bash -make backend-test +npm run backend:test ``` Test coverage with a html report in the browser. ```bash -make backend-coverage-html +npm run backend:coverage:html ``` To just print a simpler coverage report to the console. ```bash -make backend-coverage +npm run backend:coverage ``` diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 190c304d478..6d7b091f52e 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -15,13 +15,13 @@ The frontend is written in Typescript and React, as well as a few other importan The frontend can be quickly built using: ```bash -make frontend +npm run frontend:build ``` Once built, it can be run in development mode (auto-refresh) using: ```bash -make run-frontend +npm run frontend:start ``` This command leverages the `create-react-app`'s start script that launches @@ -35,7 +35,7 @@ for network request, if you need the devtools for react-query, you can simply se API documentation for TypeScript is done with [typedoc](https://typedoc.org/) and [typedoc-plugin-markdown](https://github.com/tgreyuk/typedoc-plugin-markdown), and is configured in tsconfig.json ```bash -make docs +npm run docs ``` The API output markdown is generated in docs/development/api and is not @@ -49,7 +49,7 @@ Components can be discovered, developed, and tested inside the 'storybook'. From within the [Headlamp](https://github.com/kubernetes-sigs/headlamp/) repo run: ```bash -make storybook +npm run frontend:storybook ``` If you are adding new stories, please wrap your story components with the `TestContext` helper @@ -75,7 +75,7 @@ Any issues found are reported in the developer console. To enable the alert message during development, use the following: ```bash -REACT_APP_SKIP_A11Y=false make run-frontend +REACT_APP_SKIP_A11Y=false npm run frontend:start ``` This shows an alert when an a11y issue is detected. diff --git a/docs/development/i18n/contributing.md b/docs/development/i18n/contributing.md index 0bfa660368f..1c962fe433f 100644 --- a/docs/development/i18n/contributing.md +++ b/docs/development/i18n/contributing.md @@ -91,7 +91,7 @@ Here's an example of using date formatting: Create a folder using the locale code in: `frontend/src/i18n/locales/` -Then run `make i18n`. This command parses the translatable strings in +Then run `npm run i18n`. This command parses the translatable strings in the project and creates the corresponding catalog files. Integrated components may need to be adjusted (MaterialUI/Monaco etc). diff --git a/docs/development/index.md b/docs/development/index.md index 4daa85bd075..ee060c93be1 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -16,46 +16,60 @@ These are the required dependencies to get started. Other dependencies are pulle - [Node.js](https://nodejs.org/en/download/) Latest LTS (20.11.1 at time of writing). Many of us use [nvm](https://github.com/nvm-sh/nvm) for installing multiple versions of Node. - [Go](https://go.dev/doc/install), (1.24 at time of writing) -- [Make](https://www.gnu.org/software/make/) (GNU). Often installed by default. On Windows this can be installed with the "chocolatey" package manager that is installed with node. - [Kubernetes](https://kubernetes.io/), we suggest [minikube](https://minikube.sigs.k8s.io/docs/) as one good K8s installation for testing locally. Other k8s installations are supported (see [platforms](../platforms.md). ## Build the code Headlamp is composed of a `backend` and a `frontend`. -You can build both the `backend` and `frontend` by running. +You can build both the `backend` and `frontend` by running: ```bash -make +npm run build ``` Or individually: ```bash -make backend +npm run backend:build ``` and ```bash -make frontend +npm run frontend:build ``` ## Run the code -The quickest way to get the `backend` and `frontend` running for development is -the following (respectively): +The quickest way to get the `backend` and `frontend` running for development is to run both together: ```bash -make run-backend +npm start +``` + +Or you can run them individually in separate terminal instances: + +```bash +npm run backend:start ``` and in a different terminal instance: ```bash -make run-frontend +npm run frontend:start +``` + +## Generate API documentation + +To generate the TypeScript API documentation: + +```bash +npm run docs ``` +This generates API documentation in `docs/development/api/` using TypeDoc. + ## Build the app You can build the app for Linux, Windows, or Mac. @@ -66,15 +80,15 @@ and the linux app on a linux box. Choose the relevant command: ```bash -make app-linux +npm run app:package:linux ``` ```bash -make app-mac +npm run app:package:mac ``` ```bash -make app-win +npm run app:package:win ``` For Windows, by default it will produce an installer using [NSIS (Nullsoft Scriptable Install System)](https://sourceforge.net/projects/nsis/). @@ -90,24 +104,24 @@ set PATH=%PATH%;C:\Program Files (x86)\WiX Toolset v3.11\bin Then run the following command to generate the `.msi` installer: ```bash -make app-win-msi +npm run app:package:win:msi ``` See the generated app files in app/dist/ . ### Running the app -If you already have **BOTH** the `backend` and `frontend` up and running, the quickest way to +If you already have **BOTH** the `backend` and `frontend` up and running, the quickest way to get the `app` running for development is the following: ```bash -make run-only-app +npm run app:start:client ``` or else you can simply do ```bash -make run-app +npm run start:app ``` which runs everything including the `backend`, `frontend` and `app` in parallel. @@ -137,16 +151,16 @@ source. It will run the `frontend` from a `backend`'s static server, and options can be appended to the main command as arguments. ```bash -make image +npm run image:build ``` ### Custom container base images The Dockerfile takes a build argument for the base image used. You can specify the -base image used using the IMAGE_BASE environment variable with make. +base image used using the IMAGE_BASE environment variable. ```bash -IMAGE_BASE=debian:latest make image +IMAGE_BASE=debian:latest npm run image:build ``` If no IMAGE_BASE is specified, then a default image is used (see Dockerfile for exact default image used). @@ -171,7 +185,7 @@ If you want to make a new container image called `headlamp-k8s/headlamp:developm you can run it like this: ```bash -$ DOCKER_IMAGE_VERSION=development make image +$ DOCKER_IMAGE_VERSION=development npm run image:build ... Successfully tagged headlamp-k8s/headlamp:development @@ -196,7 +210,7 @@ ones made in the local docker environment. ```bash eval $(minikube docker-env) -DOCKER_IMAGE_VERSION=development make image +DOCKER_IMAGE_VERSION=development npm run image:build ``` #### Create a deployment yaml diff --git a/docs/installation/base-url.md b/docs/installation/base-url.md index be9fdf3b992..b84979a48f8 100644 --- a/docs/installation/base-url.md +++ b/docs/installation/base-url.md @@ -22,7 +22,7 @@ If in doubt, host Headlamp on a separate origin (domain or port, don't use the ` ```bash ./backend/headlamp-server -dev -base-url /headlamp -PUBLIC_URL="/headlamp" make run-frontend +PUBLIC_URL="/headlamp" npm run frontend:start ``` Then go to in your browser. @@ -30,7 +30,7 @@ Then go to in your browser. ### Static build mode ```bash -cd frontend && npm install && npm run build && cd .. +npm run frontend:build ./backend/headlamp-server -dev -base-url /headlamp -html-static-dir frontend/build ``` diff --git a/docs/installation/in-cluster/azure-entra-id/index.md b/docs/installation/in-cluster/azure-entra-id/index.md index 6d4e8856974..bc5fd05343e 100644 --- a/docs/installation/in-cluster/azure-entra-id/index.md +++ b/docs/installation/in-cluster/azure-entra-id/index.md @@ -141,7 +141,7 @@ config: clientID: "" clientSecret: "" issuerURL: "https://login.microsoftonline.com//v2.0" - scopes: "6dae42f8-4368-4678-94ff-3960e28e3630/user.read openid email profile" + scopes: "6dae42f8-4368-4678-94ff-3960e28e3630/user.read,openid,email,profile" validatorClientID: "6dae42f8-4368-4678-94ff-3960e28e3630" validatorIssuerURL: "https://sts.windows.net//" useAccessToken: true @@ -149,6 +149,8 @@ config: Replace ``,``, and ``, with your specific Azure Entra ID app registration details obtained in the steps above. +> Note: The `scopes` string must be comma separated so each scope is passed individually to the OIDC provider. + 4. Save the `values.yaml` file and Install Headlamp using helm with the following commands: ```shell @@ -170,4 +172,4 @@ kubectl port-forward svc/headlamp-oidc 8000:80 -n headlamp 8. Open your web browser and go to . Click on "sign-in." After completing the login flow successfully, you'll gain access to your Kubernetes cluster using Headlamp. -![Headlamp Login](./headlamp_auth_screen.png) \ No newline at end of file +![Headlamp Login](./headlamp_auth_screen.png) diff --git a/docs/installation/in-cluster/oidc.md b/docs/installation/in-cluster/oidc.md index ec1afde4dc4..d0eaba255da 100644 --- a/docs/installation/in-cluster/oidc.md +++ b/docs/installation/in-cluster/oidc.md @@ -96,7 +96,7 @@ For quick reference if you are already familiar with setting up Entra ID, - Set `-oidc-client-id` to your Azure App Registration's clientID - Set `-oidc-client-secret` to your Azure App Registration's clientSecret - Set `-oidc-idp-issuer-url` to `https://login.microsoftonline.com//v2.0` -- Set `-oidc-scopes` to `6dae42f8-4368-4678-94ff-3960e28e3630/user.read openid email profile` +- Set `-oidc-scopes` to `6dae42f8-4368-4678-94ff-3960e28e3630/user.read,openid,email,profile` - Set `--oidc-validator-idp-issuer-url` to `https://sts.windows.net//` - Set `-oidc-validator-client-id` to `6dae42f8-4368-4678-94ff-3960e28e3630` - Set `-oidc-use-access-token=true` diff --git a/docs/learn/projects.md b/docs/learn/projects.md new file mode 100644 index 00000000000..72321600814 --- /dev/null +++ b/docs/learn/projects.md @@ -0,0 +1,79 @@ +--- +title: Projects +sidebar_position: 5 +--- + +# Projects + +The Projects feature in Headlamp allows you to group and manage Kubernetes resources across multiple clusters. A project combines one or more clusters under a single namespace, providing a unified view and management interface. This is particularly useful for teams working on multi-cluster deployments, as it enables easier resource organization and access control. + +By default, Headlamp provides a cluster-centric view of your Kubernetes resources. However, with Projects, you can shift to a namespace-centric perspective that spans multiple clusters. This allows you to see all resources associated with a specific project in one place, regardless of which cluster they reside in. + +## Creating a Project + +### Option 1: Create a Project in the UI + +1. From the home dashboard, open **Projects**. +2. Click **Create Project**. +3. Fill in the required details: + + - **Project Name** — A unique name for your project. + - **Cluster(s)** — Select one or more clusters to include. + - **Namespace** — Choose or create the namespace associated with this project. + +4. Click **Create** to finalize setup. + +Once created, the project appears in your list. +Selecting it opens a detailed view that includes tabs for **Overview**, **Resources**, **Access**, and **Map**. + +--- + +### Option 2: Create a Project from YAML + +You can also add resources to a project by importing a YAML configuration file. This associates existing clusters and resources into the project’s namespace. +Resources defined in your YAML file are added to the **project’s namespace** automatically. + +1. In the **Create Project** dialog, select **From YAML**. +2. Fill in the required details: + + - **Project Name** — A unique name for your project. + - **Cluster(s)** — Select a cluster to add resources to. + +3. Choose one of the following options: + - **Upload File** – Import a YAML file from your computer. + - **Use URL** – Paste a link to a hosted YAML file. +4. Click **Create** once the configuration loads. + +Once created, your projects will be listed in the "Projects" section. + +## Working with Projects + +After creating a project, you can explore it using the available tabs in the Project details view. + +### Overview Tab + +![Project Overview](https://github.com/user-attachments/assets/a03ed234-e734-47e2-86d6-2bf11bf71963) + +Provides a high-level summary of the project — similar to a namespace view, but extended across multiple clusters. +Here you can see general information like labels, annotations, and linked clusters. + +### Resources Tab + +![Project Resources](https://github.com/user-attachments/assets/fbca87df-34ad-423f-995c-3c04d72ac5b9) + +Lists most of the same resources you’d see in a cluster view, scoped to your project namespace. +Some resource types may not appear (like Pods in certain cluster configurations). +This tab aggregates resources from all clusters associated with the project. + +### Access Tab + +![Project Access](https://github.com/user-attachments/assets/c0e56948-6fdd-4a4e-b678-7cb5418cb9a3) + +Displays and manages access controls for the project, similar to how you’d manage permissions within a namespace. + +### Map Tab + +![Project Map](https://github.com/user-attachments/assets/87341cfd-3978-4555-b34b-020e4666c789) + +Shows a visual map of the resources within your project. +This view uses the same resource map as in cluster mode, but filters results to only display items belonging to your project namespace. diff --git a/e2e-tests/README.md b/e2e-tests/README.md index e1273ae33d4..6a6a1ec85ad 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -153,7 +153,7 @@ npx playwright test -g "404 page is present" --headed - Inside `.github/workflows/build-container.yml` is the source line we need, locate the step for building the image and run in your terminal: ``` - DOCKER_IMAGE_VERSION=latest make image + DOCKER_IMAGE_VERSION=latest npm run image:build ``` 2. **Tag and push the Docker image to a registry (e.g., ttl.sh):** diff --git a/e2e-tests/package-lock.json b/e2e-tests/package-lock.json index f1ca54ab23a..dbec72cb2f3 100644 --- a/e2e-tests/package-lock.json +++ b/e2e-tests/package-lock.json @@ -30,18 +30,18 @@ } }, "node_modules/@playwright/test": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz", - "integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "dependencies": { - "playwright": "1.42.1" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/@types/node": { @@ -77,32 +77,32 @@ } }, "node_modules/playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "dependencies": { - "playwright-core": "1.42.1" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/undici-types": { @@ -130,12 +130,12 @@ } }, "@playwright/test": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz", - "integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "requires": { - "playwright": "1.42.1" + "playwright": "1.56.1" } }, "@types/node": { @@ -160,19 +160,19 @@ "optional": true }, "playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.42.1" + "playwright-core": "1.56.1" } }, "playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==" + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==" }, "undici-types": { "version": "5.26.5", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4a6e7660c7d..cc0b67a2811 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,7 +84,7 @@ "simple-eval": "^2.0.0", "spacetime": "^7.4.0", "typescript": "5.6.2", - "vite": "^6.3.6", + "vite": "^6.4.1", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-static-copy": "^3.1.2", "vite-plugin-svgr": "^4.3.0" @@ -15400,9 +15400,9 @@ "dev": true }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/frontend/package.json b/frontend/package.json index 4581660e9f5..4b17cad2680 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -85,7 +85,7 @@ "simple-eval": "^2.0.0", "spacetime": "^7.4.0", "typescript": "5.6.2", - "vite": "^6.3.6", + "vite": "^6.4.1", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-static-copy": "^3.1.2", "vite-plugin-svgr": "^4.3.0" diff --git a/frontend/src/components/App/AppContainer.tsx b/frontend/src/components/App/AppContainer.tsx index 914eadd783c..05c26054aaf 100644 --- a/frontend/src/components/App/AppContainer.tsx +++ b/frontend/src/components/App/AppContainer.tsx @@ -22,6 +22,8 @@ import { getBaseUrl } from '../../helpers/getBaseUrl'; import { setBackendToken } from '../../helpers/getHeadlampAPIHeaders'; import { isElectron } from '../../helpers/isElectron'; import Plugins from '../../plugin/Plugins'; +import store from '../../redux/stores/store'; +import { uiSlice } from '../../redux/uiSlice'; import ReleaseNotes from '../common/ReleaseNotes/ReleaseNotes'; import { MonacoEditorLoaderInitializer } from '../monaco/MonacoEditorLoaderInitializer'; import Layout from './Layout'; @@ -32,6 +34,11 @@ window.desktopApi?.receive('backend-token', (token: string) => { setBackendToken(token); }); +// Listen for the open-about-dialog event from the Electron app menu +window.desktopApi?.receive('open-about-dialog', () => { + store.dispatch(uiSlice.actions.setVersionDialogOpen(true)); +}); + /** * Validates if a redirect path is safe to use * @param redirectPath - The path to validate diff --git a/frontend/src/components/App/Home/ClusterTable.tsx b/frontend/src/components/App/Home/ClusterTable.tsx index 20ed93d73a1..52e7436a7b4 100644 --- a/frontend/src/components/App/Home/ClusterTable.tsx +++ b/frontend/src/components/App/Home/ClusterTable.tsx @@ -22,10 +22,12 @@ import Typography from '@mui/material/Typography'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, useHistory } from 'react-router-dom'; +import { isElectron } from '../../../helpers/isElectron'; import { formatClusterPathParam } from '../../../lib/cluster'; import { useClustersConf, useClustersVersion } from '../../../lib/k8s'; import { ApiError } from '../../../lib/k8s/api/v2/ApiError'; import { Cluster } from '../../../lib/k8s/cluster'; +import { createRouteURL } from '../../../lib/router/createRouteURL'; import { getClusterPrefixedPath } from '../../../lib/util'; import { useTypedSelector } from '../../../redux/hooks'; import { Loader } from '../../common'; @@ -145,6 +147,42 @@ export default function ClusterTable({ return ; } + const clustersList = Object.values(customNameClusters); + if (clustersList.length === 0) { + return ( + + + + {t('No clusters found')} + + + {t('Add a cluster to get started.')} + + {isElectron() && ( + + )} + + ); + } + return ( { diff --git a/frontend/src/components/App/Layout.stories.tsx b/frontend/src/components/App/Layout.stories.tsx new file mode 100644 index 00000000000..c453eacb22a --- /dev/null +++ b/frontend/src/components/App/Layout.stories.tsx @@ -0,0 +1,282 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Meta, StoryFn } from '@storybook/react'; +import { delay, http, HttpResponse } from 'msw'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from '../../redux/stores/store'; +import { TestContext } from '../../test'; +import Layout from './Layout'; + +export default { + title: 'App/Layout', + component: Layout, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'The main layout component for Headlamp. It includes the top bar, sidebar, main content area, and handles cluster configuration and routing. This is the primary layout wrapper for the application.', + }, + }, + msw: { + handlers: [ + // Mock cluster config + http.get('http://localhost:4466/config', () => + HttpResponse.json({ + clusters: { + minikube: { + name: 'minikube', + meta_data: { namespace: 'default' }, + }, + production: { + name: 'production', + meta_data: { namespace: 'default' }, + }, + }, + }) + ), + // Mock plugins + http.get('http://localhost:4466/plugins', () => HttpResponse.json([])), + // Mock cluster version + http.get('http://localhost:4466/version', () => + HttpResponse.json({ + major: '1', + minor: '28', + gitVersion: 'v1.28.0', + }) + ), + // Mock events + http.get('http://localhost:4466/*/api/v1/events', () => + HttpResponse.json({ + kind: 'EventList', + items: [], + }) + ), + // Mock namespaces + http.get('http://localhost:4466/*/api/v1/namespaces', () => + HttpResponse.json({ + kind: 'NamespaceList', + items: [ + { + metadata: { name: 'default', uid: '1' }, + spec: {}, + status: { phase: 'Active' }, + }, + ], + }) + ), + // Mock CRDs + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions', + () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions', + () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + ], + }, + }, + decorators: [ + Story => ( + + + + + + + + ), + ], +} as Meta; + +const Template: StoryFn = () => ; + +export const Default = Template.bind({}); +Default.parameters = { + docs: { + description: { + story: 'The default layout with sidebar, topbar, and main content area.', + }, + }, +}; + +export const WithClusterRoute = Template.bind({}); +WithClusterRoute.decorators = [ + Story => ( + + + + + + + + ), +]; +WithClusterRoute.parameters = { + docs: { + description: { + story: 'Layout when viewing a specific cluster route (e.g., /c/minikube/pods).', + }, + }, +}; + +export const LoadingState = Template.bind({}); +LoadingState.parameters = { + docs: { + description: { + story: 'Layout showing loading state while fetching cluster configuration.', + }, + }, + storyshots: { + disable: true, + }, + msw: { + handlers: [ + // Delay config response to show loading for 5 seconds + http.get('http://localhost:4466/config', async () => { + await delay(5000); + return HttpResponse.json({ + clusters: { + minikube: { + name: 'minikube', + meta_data: { namespace: 'default' }, + }, + }, + }); + }), + http.get('http://localhost:4466/plugins', () => HttpResponse.json([])), + http.get('http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions', () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions', + () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + ], + }, +}; + +export const ErrorState = Template.bind({}); +ErrorState.parameters = { + docs: { + description: { + story: 'Layout showing error state when cluster configuration fails to load.', + }, + }, + msw: { + handlers: [ + http.get('http://localhost:4466/config', () => HttpResponse.error()), + http.get('http://localhost:4466/plugins', () => HttpResponse.json([])), + http.get('http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions', () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions', + () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + ], + }, +}; + +export const MultiCluster = Template.bind({}); +MultiCluster.decorators = [ + Story => ( + + + + + + + + ), +]; +MultiCluster.parameters = { + docs: { + description: { + story: 'Layout when viewing multiple clusters simultaneously.', + }, + }, + msw: { + handlers: [ + http.get('http://localhost:4466/config', () => + HttpResponse.json({ + clusters: { + minikube: { + name: 'minikube', + meta_data: { namespace: 'default' }, + }, + production: { + name: 'production', + meta_data: { namespace: 'default' }, + }, + staging: { + name: 'staging', + meta_data: { namespace: 'default' }, + }, + }, + }) + ), + http.get('http://localhost:4466/plugins', () => HttpResponse.json([])), + http.get('http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions', () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions', + () => + HttpResponse.json({ + kind: 'List', + items: [], + metadata: {}, + }) + ), + ], + }, +}; diff --git a/frontend/src/components/App/Settings/ClusterNameEditor.stories.tsx b/frontend/src/components/App/Settings/ClusterNameEditor.stories.tsx new file mode 100644 index 00000000000..3befad451c6 --- /dev/null +++ b/frontend/src/components/App/Settings/ClusterNameEditor.stories.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configureStore } from '@reduxjs/toolkit'; +import { Meta, StoryFn } from '@storybook/react'; +import { Provider } from 'react-redux'; +import { ClusterNameEditor } from './ClusterNameEditor'; + +const getMockState = () => ({ + plugins: { loaded: true }, + theme: { + name: 'light', + logo: null, + palette: { + navbar: { + background: '#fff', + }, + }, + }, +}); + +const meta: Meta = { + title: 'Settings/ClusterNameEditor', + component: ClusterNameEditor, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +const Template: StoryFn = args => { + const store = configureStore({ + reducer: (state = getMockState()) => state, + preloadedState: getMockState(), + }); + + return ( + + + + ); +}; + +export const Default = Template.bind({}); +Default.args = { + cluster: 'my-cluster', + clusterSettings: null, + setClusterSettings: () => {}, +}; + +export const WithInvalidName = Template.bind({}); +WithInvalidName.args = { + ...Default.args, + clusterSettings: { + currentName: 'Invalid Cluster Name', + }, +}; + +export const WithNewName = Template.bind({}); +WithNewName.args = { + ...Default.args, + clusterSettings: { + currentName: 'new-cluster-name', + }, +}; diff --git a/frontend/src/components/App/Settings/ClusterNameEditor.tsx b/frontend/src/components/App/Settings/ClusterNameEditor.tsx new file mode 100644 index 00000000000..45302bf9160 --- /dev/null +++ b/frontend/src/components/App/Settings/ClusterNameEditor.tsx @@ -0,0 +1,255 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Box, TextField, Typography } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { ClusterSettings } from '../../../helpers/clusterSettings'; +import { parseKubeConfig, renameCluster } from '../../../lib/k8s/api/v1/clusterApi'; +import { Cluster } from '../../../lib/k8s/cluster'; +import { setConfig, setStatelessConfig } from '../../../redux/configSlice'; +import { findKubeconfigByClusterName } from '../../../stateless/findKubeconfigByClusterName'; +import { updateStatelessClusterKubeconfig } from '../../../stateless/updateStatelessClusterKubeconfig'; +import { ConfirmButton, ConfirmDialog, NameValueTable } from '../../common'; +import { isValidClusterNameFormat } from './util'; + +interface ClusterNameEditorProps { + cluster: string; + clusterConf: { + [clusterName: string]: Cluster; + } | null; + clusterSettings: ClusterSettings | null; + setClusterSettings: React.Dispatch>; +} + +export function ClusterNameEditor({ + cluster, + clusterConf, + clusterSettings, + setClusterSettings, +}: ClusterNameEditorProps) { + const { t } = useTranslation(['translation']); + const [customNameInUse, setCustomNameInUse] = React.useState(false); + const [clusterErrorDialogOpen, setClusterErrorDialogOpen] = React.useState(false); + const [clusterErrorDialogMessage, setClusterErrorDialogMessage] = React.useState(''); + const [newClusterName, setNewClusterName] = React.useState(cluster || ''); + + const dispatch = useDispatch(); + const history = useHistory(); + + React.useEffect(() => { + if (clusterSettings?.currentName !== cluster) { + setNewClusterName(clusterSettings?.currentName || ''); + } + }, [cluster, clusterSettings]); + + const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null; + const source = clusterInfo?.meta_data?.source; + const originalName = clusterInfo?.meta_data?.originalName; + const displayName = originalName || (clusterInfo ? clusterInfo.name : ''); + + /** Note: display original name is currently only supported for non dynamic clusters from kubeconfig sources. */ + const clusterID = clusterInfo?.meta_data?.clusterID || ''; + + const invalidClusterNameMessage = t( + "translation|Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." + ); + + /** + * This function is part of a double check, this is meant to check all the cluster names currently in use as display names + * Note: if the metadata is not available or does not load, another check is done in the backend to ensure the name is unique in its own config + * + * @param name The name to check. + * @returns bool of if the name is in use. + */ + function checkNameInUse(name: string) { + if (!clusterConf) { + return false; + } + /** These are the display names of the clusters, renamed clusters have their display name as the custom name */ + const clusterNames = Object.values(clusterConf).map(cluster => cluster.name); + + /** The original name of the cluster is the name used in the kubeconfig file. */ + const originalNames = Object.values(clusterConf) + .map(cluster => cluster.meta_data?.originalName) + .filter(originalName => originalName !== undefined); + + const allNames = [...clusterNames, ...originalNames]; + + const nameInUse = allNames.includes(name); + + setCustomNameInUse(nameInUse); + } + + function ClusterErrorDialog() { + return ( + { + setClusterErrorDialogOpen(false); + }} + handleClose={() => { + setClusterErrorDialogOpen(false); + }} + hideCancelButton + open={clusterErrorDialogOpen} + title={t('translation|Error')} + description={clusterErrorDialogMessage} + confirmLabel={t('translation|Okay')} + > + ); + } + // Display the original name of the cluster if it was loaded from a kubeconfig file. + function ClusterName() { + const currentName = clusterInfo?.name; + const originalName = clusterInfo?.meta_data?.originalName; + const source = clusterInfo?.meta_data?.source; + // Note: display original name is currently only supported for non dynamic clusters from kubeconfig sources. + const displayOriginalName = source === 'kubeconfig' && originalName; + + return ( + <> + {clusterErrorDialogOpen && } + {t('translation|Name')} + {displayOriginalName && currentName !== displayOriginalName && ( + + {t('translation|Original name: {{ displayName }}', { + displayName: displayName, + })} + + )} + + ); + } + + function storeNewClusterName(name: string) { + let actualName = name; + if (name === cluster) { + actualName = ''; + setNewClusterName(actualName); + } + + setClusterSettings((settings: ClusterSettings | null) => { + const newSettings = { ...(settings || {}) }; + if (isValidClusterNameFormat(name)) { + newSettings.currentName = actualName; + } + return newSettings; + }); + } + + const handleUpdateClusterName = (source: string) => { + try { + renameCluster(cluster || '', newClusterName, source, clusterID) + .then(async config => { + if (cluster) { + const kubeconfig = await findKubeconfigByClusterName(cluster, clusterID); + if (kubeconfig !== null) { + await updateStatelessClusterKubeconfig(kubeconfig, newClusterName, cluster); + // Make another request for updated kubeconfig + const updatedKubeconfig = await findKubeconfigByClusterName(cluster, clusterID); + if (updatedKubeconfig !== null) { + parseKubeConfig({ kubeconfig: updatedKubeconfig }) + .then((config: any) => { + storeNewClusterName(newClusterName); + dispatch(setStatelessConfig(config)); + }) + .catch((err: Error) => { + console.error('Error updating cluster name:', err.message); + }); + } + } else { + dispatch(setConfig(config)); + } + } + history.push('/'); + window.location.reload(); + }) + .catch((err: Error) => { + console.error('Error updating cluster name:', err.message); + setClusterErrorDialogMessage(err.message); + setClusterErrorDialogOpen(true); + }); + } catch (error) { + console.error('Error updating cluster name:', error); + } + }; + const isValidCurrentName = isValidClusterNameFormat(newClusterName); + + return ( + , + value: ( + { + let value = event.target.value; + value = value.replace(' ', ''); + setNewClusterName(value); + checkNameInUse(value); + }} + value={newClusterName} + placeholder={cluster} + error={!isValidCurrentName || customNameInUse} + helperText={ + + {!isValidCurrentName && invalidClusterNameMessage} + {customNameInUse && + t( + 'translation|This custom name is already in use, please choose a different name.' + )} + {isValidCurrentName && + !customNameInUse && + t('translation|The current name of the cluster. You can define a custom name')} + + } + InputProps={{ + endAdornment: ( + + { + if (isValidCurrentName) { + handleUpdateClusterName(source); + } + }} + confirmTitle={t('translation|Change name')} + confirmDescription={t( + 'translation|Are you sure you want to change the name for "{{ clusterName }}"?', + { clusterName: displayName } + )} + disabled={!newClusterName || !isValidCurrentName || customNameInUse} + > + {t('translation|Apply')} + + + ), + onKeyPress: event => { + if (event.key === 'Enter' && isValidCurrentName) { + handleUpdateClusterName(source); + } + }, + autoComplete: 'off', + sx: { maxWidth: 250 }, + }} + /> + ), + }, + ]} + /> + ); +} diff --git a/frontend/src/components/App/Settings/DrawerModeSettings.tsx b/frontend/src/components/App/Settings/DrawerModeSettings.tsx index df7195fb048..459c909843a 100644 --- a/frontend/src/components/App/Settings/DrawerModeSettings.tsx +++ b/frontend/src/components/App/Settings/DrawerModeSettings.tsx @@ -143,7 +143,7 @@ const OptionButton = ({ export default function DrawerModeSettings() { const dispatch = useDispatch(); - const isDrawerEnabled = useTypedSelector(state => state.drawerMode.isDetailDrawerEnabled); + const isDrawerEnabled = useTypedSelector(state => state?.drawerMode?.isDetailDrawerEnabled); return ( diff --git a/frontend/src/components/App/Settings/SettingsCluster.tsx b/frontend/src/components/App/Settings/SettingsCluster.tsx index 207c7611d6a..538bb8e5747 100644 --- a/frontend/src/components/App/Settings/SettingsCluster.tsx +++ b/frontend/src/components/App/Settings/SettingsCluster.tsx @@ -32,33 +32,19 @@ import { } from '../../../helpers/clusterSettings'; import { isElectron } from '../../../helpers/isElectron'; import { useCluster, useClustersConf } from '../../../lib/k8s'; -import { deleteCluster, parseKubeConfig, renameCluster } from '../../../lib/k8s/api/v1/clusterApi'; -import { setConfig, setStatelessConfig } from '../../../redux/configSlice'; -import { findKubeconfigByClusterName } from '../../../stateless/findKubeconfigByClusterName'; -import { updateStatelessClusterKubeconfig } from '../../../stateless/updateStatelessClusterKubeconfig'; +import { deleteCluster } from '../../../lib/k8s/api/v1/clusterApi'; +import { setConfig } from '../../../redux/configSlice'; import ConfirmButton from '../../common/ConfirmButton'; -import ConfirmDialog from '../../common/ConfirmDialog'; import Empty from '../../common/EmptyContent'; import Link from '../../common/Link'; import Loader from '../../common/Loader'; import NameValueTable from '../../common/NameValueTable'; import SectionBox from '../../common/SectionBox'; +import { ClusterNameEditor } from './ClusterNameEditor'; import ClusterSelector from './ClusterSelector'; import NodeShellSettings from './NodeShellSettings'; import { isValidNamespaceFormat } from './util'; -function isValidClusterNameFormat(name: string) { - // We allow empty isValidClusterNameFormat just because that's the default value in our case. - if (!name) { - return true; - } - - // Validates that the namespace is a valid DNS-1123 label and returns a boolean. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names - const regex = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'); - return regex.test(name); -} - export default function SettingsCluster() { const clusterConf = useClustersConf(); const clusters = Object.values(clusterConf || {}).map(cluster => cluster.name); @@ -69,10 +55,6 @@ export default function SettingsCluster() { const [clusterSettings, setClusterSettings] = React.useState(null); const [cluster, setCluster] = React.useState(useCluster() || ''); const clusterFromURLRef = React.useRef(''); - const [newClusterName, setNewClusterName] = React.useState(cluster || ''); - const [clusterErrorDialogOpen, setClusterErrorDialogOpen] = React.useState(false); - const [clusterErrorDialogMessage, setClusterErrorDialogMessage] = React.useState(''); - const [customNameInUse, setCustomNameInUse] = React.useState(false); const theme = useTheme(); @@ -80,77 +62,6 @@ export default function SettingsCluster() { const dispatch = useDispatch(); const location = useLocation(); - const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null; - const originalName = clusterInfo?.meta_data?.originalName; - const displayName = originalName || (clusterInfo ? clusterInfo.name : ''); - const source = clusterInfo?.meta_data?.source; - /** Note: display original name is currently only supported for non dynamic clusters from kubeconfig sources. */ - const clusterID = clusterInfo?.meta_data?.clusterID || ''; - - /** - * This function is part of a double check, this is meant to check all the cluster names currently in use as display names - * Note: if the metadata is not available or does not load, another check is done in the backend to ensure the name is unique in its own config - * - * @param name The name to check. - * @returns bool of if the name is in use. - */ - function checkNameInUse(name: string) { - if (!clusterConf) { - return false; - } - - /** These are the display names of the clusters, renamed clusters have their display name as the custom name */ - const clusterNames = Object.values(clusterConf).map(cluster => cluster.name); - - /** The original name of the cluster is the name used in the kubeconfig file. */ - const originalNames = Object.values(clusterConf) - .map(cluster => cluster.meta_data?.originalName) - .filter(originalName => originalName !== undefined); - - const allNames = [...clusterNames, ...originalNames]; - - const nameInUse = allNames.includes(name); - - setCustomNameInUse(nameInUse); - } - - const handleUpdateClusterName = (source: string) => { - try { - renameCluster(cluster || '', newClusterName, source, clusterID) - .then(async config => { - if (cluster) { - const kubeconfig = await findKubeconfigByClusterName(cluster, clusterID); - if (kubeconfig !== null) { - await updateStatelessClusterKubeconfig(kubeconfig, newClusterName, cluster); - // Make another request for updated kubeconfig - const updatedKubeconfig = await findKubeconfigByClusterName(cluster, clusterID); - if (updatedKubeconfig !== null) { - parseKubeConfig({ kubeconfig: updatedKubeconfig }) - .then((config: any) => { - storeNewClusterName(newClusterName); - dispatch(setStatelessConfig(config)); - }) - .catch((err: Error) => { - console.error('Error updating cluster name:', err.message); - }); - } - } else { - dispatch(setConfig(config)); - } - } - history.push('/'); - window.location.reload(); - }) - .catch((err: Error) => { - console.error('Error updating cluster name:', err.message); - setClusterErrorDialogMessage(err.message); - setClusterErrorDialogOpen(true); - }); - } catch (error) { - console.error('Error updating cluster name:', error); - } - }; - const removeCluster = () => { deleteCluster(cluster || '') .then(config => { @@ -191,10 +102,6 @@ export default function SettingsCluster() { setUserDefaultNamespace(clusterSettings?.defaultNamespace || ''); } - if (clusterSettings?.currentName !== cluster) { - setNewClusterName(clusterSettings?.currentName || ''); - } - // Avoid re-initializing settings as {} just because the cluster is not yet set. if (clusterSettings !== null) { storeClusterSettings(cluster || '', clusterSettings); @@ -266,33 +173,12 @@ export default function SettingsCluster() { }); } - function storeNewClusterName(name: string) { - let actualName = name; - if (name === cluster) { - actualName = ''; - setNewClusterName(actualName); - } - - setClusterSettings((settings: ClusterSettings | null) => { - const newSettings = { ...(settings || {}) }; - if (isValidClusterNameFormat(name)) { - newSettings.currentName = actualName; - } - return newSettings; - }); - } - const isValidDefaultNamespace = isValidNamespaceFormat(userDefaultNamespace); - const isValidCurrentName = isValidClusterNameFormat(newClusterName); const isValidNewAllowedNamespace = isValidNamespaceFormat(newAllowedNamespace); const invalidNamespaceMessage = t( "translation|Namespaces must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." ); - const invalidClusterNameMessage = t( - "translation|Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." - ); - // If we don't have yet a cluster name from the URL, we are still loading. if (!clusterFromURLRef.current) { return ; @@ -331,47 +217,6 @@ export default function SettingsCluster() { ); } - function ClusterErrorDialog() { - return ( - { - setClusterErrorDialogOpen(false); - }} - handleClose={() => { - setClusterErrorDialogOpen(false); - }} - hideCancelButton - open={clusterErrorDialogOpen} - title={t('translation|Error')} - description={clusterErrorDialogMessage} - confirmLabel={t('translation|Okay')} - > - ); - } - - // Display the original name of the cluster if it was loaded from a kubeconfig file. - function ClusterName() { - const currentName = clusterInfo?.name; - const originalName = clusterInfo?.meta_data?.originalName; - const source = clusterInfo?.meta_data?.source; - // Note: display original name is currently only supported for non dynamic clusters from kubeconfig sources. - const displayOriginalName = source === 'kubeconfig' && originalName; - - return ( - <> - {clusterErrorDialogOpen && } - {t('translation|Name')} - {displayOriginalName && currentName !== displayOriginalName && ( - - {t('translation|Original name: {{ displayName }}', { - displayName: displayName, - })} - - )} - - ); - } - const defaultNamespaceLabelID = 'default-namespace-label'; const allowedNamespaceLabelID = 'allowed-namespace-label'; @@ -389,67 +234,11 @@ export default function SettingsCluster() { {isElectron() && ( - , - value: ( - { - let value = event.target.value; - value = value.replace(' ', ''); - setNewClusterName(value); - checkNameInUse(value); - }} - value={newClusterName} - placeholder={cluster} - error={!isValidCurrentName || customNameInUse} - helperText={ - - {!isValidCurrentName && invalidClusterNameMessage} - {customNameInUse && - t( - 'translation|This custom name is already in use, please choose a different name.' - )} - {isValidCurrentName && - !customNameInUse && - t( - 'translation|The current name of the cluster. You can define a custom name.' - )} - - } - InputProps={{ - endAdornment: ( - - { - if (isValidCurrentName) { - handleUpdateClusterName(source); - } - }} - confirmTitle={t('translation|Change name')} - confirmDescription={t( - 'translation|Are you sure you want to change the name for "{{ clusterName }}"?', - { clusterName: displayName } - )} - disabled={!newClusterName || !isValidCurrentName || customNameInUse} - > - {t('translation|Apply')} - - - ), - onKeyPress: event => { - if (event.key === 'Enter' && isValidCurrentName) { - handleUpdateClusterName(source); - } - }, - autoComplete: 'off', - sx: { maxWidth: 250 }, - }} - /> - ), - }, - ]} + )} +
+
+
+

+ Name +

+
+
+
+
+ +
+ +
+
+
+

+

+ The current name of the cluster. You can define a custom name +

+

+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithInvalidName.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithInvalidName.stories.storyshot new file mode 100644 index 00000000000..2625f4c2e52 --- /dev/null +++ b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithInvalidName.stories.storyshot @@ -0,0 +1,62 @@ + +
+
+
+

+ Name +

+
+
+
+
+ +
+ +
+
+
+

+

+ Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character. +

+

+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithNewName.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithNewName.stories.storyshot new file mode 100644 index 00000000000..6bbba097c2a --- /dev/null +++ b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithNewName.stories.storyshot @@ -0,0 +1,64 @@ + +
+
+
+

+ Name +

+
+
+
+
+ +
+ +
+
+
+

+

+ The current name of the cluster. You can define a custom name +

+

+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot index 17f9761c20e..2fcc03fe65f 100644 --- a/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot +++ b/frontend/src/components/App/Settings/__snapshots__/NodeShellSettings.Default.stories.storyshot @@ -81,7 +81,7 @@ class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-colorPrimary MuiInputBase-formControl MuiInputBase-sizeSmall MuiInputBase-adornedEnd css-14f1a7d-MuiInputBase-root-MuiOutlinedInput-root" > - + + + ); @@ -119,3 +124,47 @@ TwoCluster.args = { cluster: 'ak8s-desktop', clusters: { 'ak8s-desktop': '', 'ak8s-desktop2': '' }, }; + +export const WithUserInfo = PureTemplate.bind({}); +WithUserInfo.args = { + appBarActions: [], + logout: () => {}, + cluster: 'ak8s-desktop', + clusters: { 'ak8s-desktop': '' }, + userInfo: { + username: 'Ada Lovelace', + email: 'ada@example.com', + }, +}; + +export const WithEmailOnly = PureTemplate.bind({}); +WithEmailOnly.args = { + appBarActions: [], + logout: () => {}, + cluster: 'ak8s-desktop', + clusters: { 'ak8s-desktop': '' }, + userInfo: { + email: 'grace@example.com', + }, +}; + +export const UndefinedData = PureTemplate.bind({}); +UndefinedData.args = { + appBarActions: [], + logout: () => {}, + cluster: 'ak8s-desktop', + clusters: { 'ak8s-desktop': '' }, + userInfo: undefined, +}; + +export const EmptyUserInfo = PureTemplate.bind({}); +EmptyUserInfo.args = { + appBarActions: [], + logout: () => {}, + cluster: 'ak8s-desktop', + clusters: { 'ak8s-desktop': '' }, + userInfo: { + email: '', + username: '', + }, +}; diff --git a/frontend/src/components/App/TopBar.tsx b/frontend/src/components/App/TopBar.tsx index 39075f1e6f8..fe3d4aa92b5 100644 --- a/frontend/src/components/App/TopBar.tsx +++ b/frontend/src/components/App/TopBar.tsx @@ -25,6 +25,7 @@ import MenuItem from '@mui/material/MenuItem'; import { useTheme } from '@mui/material/styles'; import Toolbar from '@mui/material/Toolbar'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { has } from 'lodash'; import React, { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -33,6 +34,7 @@ import { useHistory } from 'react-router-dom'; import { getProductName, getVersion } from '../../helpers/getProductInfo'; import { logout } from '../../lib/auth'; import { useCluster, useClustersConf } from '../../lib/k8s'; +import { clusterRequest } from '../../lib/k8s/api/v1/clusterRequests'; import { createRouteURL } from '../../lib/router/createRouteURL'; import { AppBarAction, @@ -86,13 +88,68 @@ export default function TopBar({}: TopBarProps) { const cluster = useCluster(); const history = useHistory(); const { appBarActions, appBarActionsProcessors } = useAppBarActionsProcessed(); + const queryClient = useQueryClient(); + const clusterName = cluster ?? undefined; + const { data: me } = useQuery<{ username?: string; email?: string } | null>({ + queryKey: ['clusterMe', clusterName], + queryFn: async () => { + if (!clusterName) { + return null; + } + + try { + const res = await clusterRequest('/me', { + cluster: clusterName, + autoLogoutOnAuthError: false, + }); + + if (!res) { + return null; + } + + if (!(typeof res.userInfoURL === 'string' && res.userInfoURL.length > 0)) { + return { username: res.username, email: res.email }; + } + + const ui: { + preferredUsername?: string; + email?: string; + } = await fetch(res.userInfoURL, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }).then(r => { + if (!r.ok) { + throw new Error(`Could not fetch user info from ${res.userInfoURL}`); + } + return r.json(); + }); + + if (!ui || (!ui.preferredUsername && !ui.email)) { + return null; + } + + return { + username: ui.preferredUsername, + email: ui.email, + }; + } catch { + return null; + } + }, + enabled: Boolean(clusterName), + staleTime: 0, + refetchOnMount: 'always', + }); const logoutCallback = useCallback(async () => { if (!!cluster) { await logout(cluster); + queryClient.removeQueries({ queryKey: ['clusterMe', cluster], exact: true }); } history.push('/'); - }, [cluster]); + }, [cluster, history, queryClient]); const handletoggleOpen = useCallback(() => { // For medium view we default to closed if they have not made a selection. @@ -118,6 +175,7 @@ export default function TopBar({}: TopBarProps) { onToggleOpen={handletoggleOpen} cluster={cluster || undefined} clusters={clustersConfig || undefined} + userInfo={me || undefined} /> ); } @@ -134,6 +192,7 @@ export interface PureTopBarProps { cluster?: string; isSidebarOpen?: boolean; isSidebarOpenUserSelected?: boolean; + userInfo?: { username?: string; email?: string }; /** Called when sidebar toggles between open and closed. */ onToggleOpen: () => void; @@ -210,6 +269,7 @@ export const PureTopBar = memo( isSidebarOpen, isSidebarOpenUserSelected, onToggleOpen, + userInfo, }: PureTopBarProps) => { const { t } = useTranslation(); const theme = useTheme(); @@ -243,6 +303,11 @@ export const PureTopBar = memo( setMobileMoreAnchorEl(event.currentTarget); }; const userMenuId = 'primary-user-menu'; + const userDisplayName = userInfo?.username || userInfo?.email || ''; + const userSecondaryInfo = + userInfo?.username && userInfo?.email && userInfo.username !== userInfo.email + ? userInfo.email + : undefined; const renderUserMenu = !!isClusterContext && ( + {!!userDisplayName && ( + + + + + + + )} { diff --git a/frontend/src/components/App/__snapshots__/Layout.Default.stories.storyshot b/frontend/src/components/App/__snapshots__/Layout.Default.stories.storyshot new file mode 100644 index 00000000000..06c2eda5829 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/Layout.Default.stories.storyshot @@ -0,0 +1,113 @@ + +
+ + Skip to main content + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/__snapshots__/Layout.ErrorState.stories.storyshot b/frontend/src/components/App/__snapshots__/Layout.ErrorState.stories.storyshot new file mode 100644 index 00000000000..06c2eda5829 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/Layout.ErrorState.stories.storyshot @@ -0,0 +1,113 @@ + +
+ + Skip to main content + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/__snapshots__/Layout.MultiCluster.stories.storyshot b/frontend/src/components/App/__snapshots__/Layout.MultiCluster.stories.storyshot new file mode 100644 index 00000000000..06c2eda5829 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/Layout.MultiCluster.stories.storyshot @@ -0,0 +1,113 @@ + +
+ + Skip to main content + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/__snapshots__/Layout.WithClusterRoute.stories.storyshot b/frontend/src/components/App/__snapshots__/Layout.WithClusterRoute.stories.storyshot new file mode 100644 index 00000000000..06c2eda5829 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/Layout.WithClusterRoute.stories.storyshot @@ -0,0 +1,113 @@ + +
+ + Skip to main content + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/__snapshots__/RouteSwitcher.Default.stories.storyshot b/frontend/src/components/App/__snapshots__/RouteSwitcher.Default.stories.storyshot index 8f65798cdfe..89d76cce9da 100644 --- a/frontend/src/components/App/__snapshots__/RouteSwitcher.Default.stories.storyshot +++ b/frontend/src/components/App/__snapshots__/RouteSwitcher.Default.stories.storyshot @@ -68,7 +68,7 @@ class="MuiAccordionDetails-root css-15v22id-MuiAccordionDetails-root" >
+
+  
+ +
+ \ No newline at end of file diff --git a/frontend/src/components/App/__snapshots__/TopBar.UndefinedData.stories.storyshot b/frontend/src/components/App/__snapshots__/TopBar.UndefinedData.stories.storyshot new file mode 100644 index 00000000000..0f1b47e9aa5 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/TopBar.UndefinedData.stories.storyshot @@ -0,0 +1,137 @@ + +
+ +
+ \ No newline at end of file diff --git a/frontend/src/components/App/__snapshots__/TopBar.WithEmailOnly.stories.storyshot b/frontend/src/components/App/__snapshots__/TopBar.WithEmailOnly.stories.storyshot new file mode 100644 index 00000000000..0f1b47e9aa5 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/TopBar.WithEmailOnly.stories.storyshot @@ -0,0 +1,137 @@ + +
+ +
+ \ No newline at end of file diff --git a/frontend/src/components/App/__snapshots__/TopBar.WithUserInfo.stories.storyshot b/frontend/src/components/App/__snapshots__/TopBar.WithUserInfo.stories.storyshot new file mode 100644 index 00000000000..0f1b47e9aa5 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/TopBar.WithUserInfo.stories.storyshot @@ -0,0 +1,137 @@ + +
+ +
+ \ No newline at end of file diff --git a/frontend/src/components/Sidebar/Sidebar.tsx b/frontend/src/components/Sidebar/Sidebar.tsx index e8c5d0981e4..4c870ba7b0d 100644 --- a/frontend/src/components/Sidebar/Sidebar.tsx +++ b/frontend/src/components/Sidebar/Sidebar.tsx @@ -385,6 +385,7 @@ export const PureSidebar = memo( variant={isTemporaryDrawer ? 'temporary' : 'permanent'} PaperProps={{ sx: { + borderTop: 'none', position: 'initial', }, }} diff --git a/frontend/src/components/Sidebar/__snapshots__/Sidebar.HomeSidebarClosed.stories.storyshot b/frontend/src/components/Sidebar/__snapshots__/Sidebar.HomeSidebarClosed.stories.storyshot index 45ec8b0137b..f47c69b93db 100644 --- a/frontend/src/components/Sidebar/__snapshots__/Sidebar.HomeSidebarClosed.stories.storyshot +++ b/frontend/src/components/Sidebar/__snapshots__/Sidebar.HomeSidebarClosed.stories.storyshot @@ -8,7 +8,7 @@ class="MuiDrawer-root MuiDrawer-docked css-ohfc2x-MuiDrawer-docked" >