diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5d0dfa2..480614b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,26 +1,26 @@ FROM mcr.microsoft.com/devcontainers/javascript-node:24-bookworm -FROM --platform=linux/amd64 quay.io/openshift/origin-cli:5.1 AS openshift_cli - FROM mcr.microsoft.com/devcontainers/javascript-node:24-bookworm +ARG OC_VERSION=4.22.11 +ARG TARGETARCH ARG KUBECTL_VERSION=v1.33.3 ARG HELM_VERSION=v3.18.4 COPY scripts/install-kubernetes-tools.sh /tmp/install-kubernetes-tools.sh -COPY --from=openshift_cli /usr/bin/oc /usr/local/bin/oc.amd64 RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends ca-certificates curl tar gzip; \ - chmod +x /usr/local/bin/oc.amd64; \ - printf '%s\n' '#!/bin/sh' \ - 'if [ "$(uname -m)" = "x86_64" ]; then' \ - ' exec /usr/local/bin/oc.amd64 "$@"' \ - 'fi' \ - 'echo "OpenShift oc 5.1 is only available for amd64 in this image build." >&2' \ - 'exit 1' > /usr/local/bin/oc; \ + case "$TARGETARCH" in \ + amd64) oc_archive="openshift-client-linux-${OC_VERSION}.tar.gz" ;; \ + arm64) oc_archive="openshift-client-linux-arm64-${OC_VERSION}.tar.gz" ;; \ + *) echo "Unsupported OpenShift CLI architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /tmp/openshift-client.tar.gz "https://mirror.openshift.com/pub/openshift-v4/clients/ocp/${OC_VERSION}/${oc_archive}"; \ + tar -xzf /tmp/openshift-client.tar.gz -C /usr/local/bin oc; \ chmod +x /usr/local/bin/oc; \ + rm -f /tmp/openshift-client.tar.gz; \ rm -rf /var/lib/apt/lists/*; \ chmod +x /tmp/install-kubernetes-tools.sh; \ KUBECTL_VERSION="$KUBECTL_VERSION" HELM_VERSION="$HELM_VERSION" /tmp/install-kubernetes-tools.sh; \ diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 8ed9954..3ba89d6 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -19,7 +19,6 @@ services: required: false environment: KUBECONFIG: ${KUBECONFIG:-/home/node/.kube/k3s.yaml} - KUBERNETES_API_URL: ${KUBERNETES_API_URL:-https://k3s-single-node:6443} extra_hosts: - "host.docker.internal:host-gateway" volumes: diff --git a/.env.example b/.env.example index 0d7b8ff..b16edee 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,14 @@ # Framework ENV Vars # DOCKER_API_URL=tcp://host.docker.internal:2375 # Set to use the Docker API (instead of docker.sock) when the framework makes docker calls. -# KUBERNETES_API_URL=https://k3s-single-node:6443 # Optional API endpoint for cluster-aware framework features. # KUBECONFIG=/app/.kube/k3s.yaml # Kubeconfig mounted from the peer K3s service. # For OpenShift, mount ./openshift/kubeconfig.yaml and set KUBECONFIG=/app/.kube-openshift/kubeconfig.yaml. -# For OpenShift, set KUBERNETES_API_URL to your cluster API endpoint. DEPLOYMENT_IDENTIFIER_RESPONSE_FIELD=petname # DEPLOYMENT_IDENTIFIER_API_URL=http://host.docker.internal:5123/petname # API endpoint for retrieving deployment identifier # LABINFO_API_URL=http://host.docker.internal:5123/metadata # API endpoint for retrieving lab information # UDF_DEPLOYMENT_API_URL=http://metadata.udf/deployment # API endpoint for UDF deployment metadata +# Document source switch: REMOTE_DOCS_REPO_SERVER decides where MD(X) documents are loaded from. +# Set (below) = documents are fetched from the remote repo defined by the other REMOTE_DOCS_* values. +# Commented out = documents are read from the local src/app/docs folder, and all REMOTE_DOCS_* values are ignored. REMOTE_DOCS_REPO_SERVER="https://raw.githubusercontent.com" REMOTE_DOCS_REPO_API_SERVER="https://api.github.com" REMOTE_DOCS_REPO_OWNER=f5devcentral diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1e7cc96..497cbfe 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,3 +18,8 @@ updates: directory: "/" schedule: interval: weekly + ignore: + - dependency-name: "eslint" + update-types: ["version-update:semver-major"] + - dependency-name: "@eslint/js" + update-types: ["version-update:semver-major"] diff --git a/Dockerfile b/Dockerfile index 7cec694..33e62c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,30 +33,30 @@ RUN \ else echo "Lockfile not found." && exit 1; \ fi -FROM --platform=linux/amd64 quay.io/openshift/origin-cli:5.1 AS openshift_cli - # Production image, copy all the files and run next FROM base AS runner WORKDIR /app +ARG OC_VERSION=4.22.11 +ARG TARGETARCH ARG KUBECTL_VERSION=v1.33.3 ARG HELM_VERSION=v3.18.4 COPY scripts/install-kubernetes-tools.sh /tmp/install-kubernetes-tools.sh -COPY --from=openshift_cli /usr/bin/oc /usr/local/bin/oc.amd64 -# Add Docker CLI, kubectl, and Helm for container and Kubernetes workflows. +# Add Docker CLI, OpenShift CLI, kubectl, and Helm for container and Kubernetes workflows. RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends docker.io curl ca-certificates tar gzip; \ - chmod +x /usr/local/bin/oc.amd64; \ - printf '%s\n' '#!/bin/sh' \ - 'if [ "$(uname -m)" = "x86_64" ]; then' \ - ' exec /usr/local/bin/oc.amd64 "$@"' \ - 'fi' \ - 'echo "OpenShift oc 5.1 is only available for amd64 in this image build." >&2' \ - 'exit 1' > /usr/local/bin/oc; \ + case "$TARGETARCH" in \ + amd64) oc_archive="openshift-client-linux-${OC_VERSION}.tar.gz" ;; \ + arm64) oc_archive="openshift-client-linux-arm64-${OC_VERSION}.tar.gz" ;; \ + *) echo "Unsupported OpenShift CLI architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /tmp/openshift-client.tar.gz "https://mirror.openshift.com/pub/openshift-v4/clients/ocp/${OC_VERSION}/${oc_archive}"; \ + tar -xzf /tmp/openshift-client.tar.gz -C /usr/local/bin oc; \ chmod +x /usr/local/bin/oc; \ + rm -f /tmp/openshift-client.tar.gz; \ rm -rf /var/lib/apt/lists/*; \ chmod +x /tmp/install-kubernetes-tools.sh; \ KUBECTL_VERSION="$KUBECTL_VERSION" HELM_VERSION="$HELM_VERSION" /tmp/install-kubernetes-tools.sh; \ diff --git a/README.md b/README.md index 5867d71..8e35012 100644 --- a/README.md +++ b/README.md @@ -43,22 +43,34 @@ npm run dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the lab markdown page by modifying `app/docs/nginx-one.mdx`. The page auto-updates as you edit the file. +You can start editing the lab markdown page by modifying the MD(X) files in `src/app/docs`. The page auto-updates as you edit the file. See [Document Source](#document-source) for how to switch between local and remote documents. -An optional Kubernetes peer cluster profile is available in the devcontainer Compose stack: +An optional Kubernetes peer cluster is available in the devcontainer Compose stack, gated behind the `k3s` Compose profile. It does not start by default, and Kubernetes commands report that the cluster is unavailable until it is enabled. + +To enable it for every rebuild, create a `.devcontainer/.env` file containing: ```shell -# Rebuild devcontainer with default services (no optional k3s profile) -Dev Containers: Rebuild Container +COMPOSE_PROFILES=k3s +``` + +Then run `Dev Containers: Rebuild Container` from the command palette. Compose reads that file from the devcontainer project directory, so the profile is applied however VS Code is launched. The file is ignored by git, so it stays a per-developer setting. + +As an alternative, export the variable in the shell you launch VS Code from: -# Rebuild devcontainer with optional k3s peer cluster -COMPOSE_PROFILES=k3s Dev Containers: Rebuild Container +```shell +export COMPOSE_PROFILES=k3s +code . +``` + +To start the cluster immediately without rebuilding the devcontainer: + +```shell +docker compose -f .devcontainer/docker-compose.yml --profile k3s up -d k3s-single-node ``` For OpenShift access in devcontainer mode, provide `./openshift/kubeconfig.yaml` in the repository workspace and set: 1. `KUBECONFIG=/home/node/.kube-openshift/kubeconfig.yaml` -1. `KUBERNETES_API_URL=https://api.your-openshift.example:6443` ## "Production" Docker Deployment @@ -130,9 +142,8 @@ OpenShift access is also available for connecting the framework to an external O # Prepare an OpenShift kubeconfig at this path: # ./openshift/kubeconfig.yaml -# Point the app at the OpenShift kubeconfig and API endpoint +# Point the app at the OpenShift kubeconfig export KUBECONFIG=/app/.kube-openshift/kubeconfig.yaml -export KUBERNETES_API_URL=https://api.your-openshift.example:6443 # Start the framework stack docker compose up -d @@ -162,13 +173,52 @@ The framework and the K3s container should share a kubeconfig or equivalent acce ### OpenShift Option -The framework image includes the OpenShift `oc` client pinned to v5.1. Compose mounts `./openshift` into the framework container at `/app/.kube-openshift` (and `/home/node/.kube-openshift` in devcontainer mode), so OpenShift access uses your provided kubeconfig directly. +The framework image includes the OpenShift `oc` client pinned to v4.22.11, built natively for both `amd64` and `arm64`. Compose mounts `./openshift` into the framework container at `/app/.kube-openshift` (and `/home/node/.kube-openshift` in devcontainer mode), so OpenShift access uses your provided kubeconfig directly. -Use the following environment variables to point the framework at OpenShift: +Use the following environment variable to point the framework at OpenShift: 1. `KUBECONFIG=/app/.kube-openshift/kubeconfig.yaml` -1. `KUBERNETES_API_URL=https://api.your-openshift.example:6443` ### Environment You will need to create your own `/.env` file to use remote MDX documents. Use the `/.env.example` as a template. + +### Document Source + +The framework loads lab documents from either the local file system or a remote GitHub repository. A single environment variable, `REMOTE_DOCS_REPO_SERVER`, controls which source is used. + +| `REMOTE_DOCS_REPO_SERVER` | Document source | +|---------------------------|-------------------------------------------------------| +| Set | Remote repository defined by the other `REMOTE_DOCS_*` values | +| Unset or commented out | Local `src/app/docs` folder | + +#### Local documents + +Comment out `REMOTE_DOCS_REPO_SERVER` in your `.env` file: + +```shell +# REMOTE_DOCS_REPO_SERVER="https://raw.githubusercontent.com" +``` + +Then place your `.md` or `.mdx` files in `src/app/docs`. The remaining `REMOTE_DOCS_*` values are ignored in this mode, so they can be left in place. + +Each document needs frontmatter, because the document index is sorted by `order`: + +```mdx +--- +title: My Lab +description: What this page covers +order: 1 +--- +``` + +Notes for local mode: + +1. Local files are read per request, so edits appear on refresh and `REMOTE_DOCS_REPO_CACHE_SECONDS` does not apply. +1. Relative image rewriting is only applied to remote documents. Reference images from the `public` folder instead, for example `/media/diagram.png`. + +#### Remote documents + +Set `REMOTE_DOCS_REPO_SERVER` along with the other `REMOTE_DOCS_*` values to fetch documents from a GitHub repository. Responses are cached for `REMOTE_DOCS_REPO_CACHE_SECONDS`, and `REMOTE_DOCS_REPO_MEDIA_PATH` is used to resolve relative image paths in the remote content. + +Environment variables are read when the server starts, so restart the application after changing the document source. diff --git a/docker-compose.yml b/docker-compose.yml index b2993f6..84ab48f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,6 @@ services: required: false environment: KUBECONFIG: ${KUBECONFIG:-/app/.kube/k3s.yaml} - KUBERNETES_API_URL: ${KUBERNETES_API_URL:-https://k3s-single-node:6443} extra_hosts: - "host.docker.internal:host-gateway" volumes: diff --git a/eslint.config.mjs b/eslint.config.mjs index 727fb3a..112a3f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,21 @@ +import { createRequire } from "module"; import { defineConfig, globalIgnores } from "eslint/config"; import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; import nextTypeScript from "eslint-config-next/typescript"; +// eslint-plugin-react's "detect" mode uses context.getFilename(), removed in ESLint 10. +const REACT_VERSION = createRequire(import.meta.url)("react/package.json").version; + export default defineConfig([ ...nextCoreWebVitals, ...nextTypeScript, + { + settings: { + react: { + version: REACT_VERSION, + }, + }, + }, globalIgnores([ ".next/**", "out/**", diff --git a/jest.setup.ts b/jest.setup.ts index fcd35ac..ed83127 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -11,7 +11,7 @@ jest.mock("next/image", () => ({ alt?: string; [key: string]: unknown; }) => { - const React = require("react"); + const React = jest.requireActual("react"); const { src, alt, ...rest } = props; return React.createElement("img", { diff --git a/package-lock.json b/package-lock.json index bf759df..2c007d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "^10.0.1", + "@eslint/js": "^9.39.5", "@swc/core": "^1.15.46", "@swc/jest": "^0.2.39", "@tailwindcss/postcss": "^4.3.3", @@ -37,7 +37,7 @@ "@types/react": "19.2.18", "@types/react-dom": "19.2.3", "@types/react-syntax-highlighter": "^15.5.13", - "eslint": "^10.8.0", + "eslint": "^9.39.5", "eslint-config-next": "^16.2.10", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", @@ -827,83 +827,44 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.5", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.2.4" + "minimatch": "^3.1.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1" + "@eslint/core": "^0.17.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/eslintrc": { @@ -931,48 +892,40 @@ } }, "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } } }, "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@grpc/grpc-js": { @@ -3449,13 +3402,6 @@ "@types/ssh2": "*" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5741,33 +5687,34 @@ } }, "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" - ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -5777,7 +5724,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5785,7 +5733,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -6432,19 +6380,17 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6463,76 +6409,19 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -9729,6 +9618,13 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", diff --git a/package.json b/package.json index 2a5ac28..2e00f04 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "^10.0.1", + "@eslint/js": "^9.39.5", "@swc/core": "^1.15.46", "@swc/jest": "^0.2.39", "@tailwindcss/postcss": "^4.3.3", @@ -43,7 +43,7 @@ "@types/react": "19.2.18", "@types/react-dom": "19.2.3", "@types/react-syntax-highlighter": "^15.5.13", - "eslint": "^10.8.0", + "eslint": "^9.39.5", "eslint-config-next": "^16.2.10", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", diff --git a/src/app/components/docker.test.tsx b/src/app/components/docker.test.tsx index 84b04ac..13f47ae 100644 --- a/src/app/components/docker.test.tsx +++ b/src/app/components/docker.test.tsx @@ -2,7 +2,6 @@ import { render, fireEvent, screen, act, waitFor } from "@testing-library/react" import { InstancesContextType } from "../contexts/instances"; import { getContainerLogs } from "@/app/lib/docker-lib"; -import { getContainerPorts } from "@/app/lib/docker-lib"; import { syncDockerInstances } from "@/app/lib/docker-instance-sync"; import { checkAPI } from "@/lib/check-api"; import { getComponentName } from "@/lib/variables"; diff --git a/src/app/components/kubernetes-shell.test.tsx b/src/app/components/kubernetes-shell.test.tsx new file mode 100644 index 0000000..b502fcf --- /dev/null +++ b/src/app/components/kubernetes-shell.test.tsx @@ -0,0 +1,243 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { KubernetesShell } from "./kubernetes-shell"; +import { runKubernetesCliCommand } from "@/lib/kubernetes-cli-action"; + +jest.mock("@/lib/kubernetes-cli-action", () => ({ + runKubernetesCliCommand: jest.fn(), +})); + +const runCommand = (value: string) => { + fireEvent.change(screen.getByLabelText("Kubernetes shell command"), { + target: { value }, + }); + fireEvent.click(screen.getByRole("button")); +}; + +describe("KubernetesShell component", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders the allowed commands and empty state", () => { + render(); + + expect(screen.getByText("Allowed commands: oc, kubectl")).toBeInTheDocument(); + expect( + screen.getByText("Run oc, kubectl commands against the lab cluster.") + ).toBeInTheDocument(); + }); + + it("renders a custom title", () => { + render(); + + expect(screen.getByText("Cluster Console")).toBeInTheDocument(); + }); + + it("clears the input placeholder after a command has been executed", async () => { + (runKubernetesCliCommand as jest.Mock).mockResolvedValue({ + command: "kubectl get pods", + output: "pod/nginx Running", + exitCode: 0, + isError: false, + }); + + render(); + const input = screen.getByLabelText("Kubernetes shell command") as HTMLInputElement; + + expect(input.placeholder).toBe("kubectl get pods"); + + runCommand("kubectl get pods"); + await waitFor(() => expect(input.placeholder).toBe("")); + }); + + it("shows command output on success", async () => { + (runKubernetesCliCommand as jest.Mock).mockResolvedValue({ + command: "kubectl get pods", + output: "pod/nginx Running", + exitCode: 0, + isError: false, + }); + + render(); + runCommand("kubectl get pods"); + + expect(await screen.findByText("pod/nginx Running")).toBeInTheDocument(); + expect(screen.getByText("$ kubectl get pods")).toBeInTheDocument(); + expect(runKubernetesCliCommand).toHaveBeenCalledWith("kubectl get pods", { + context: undefined, + namespace: undefined, + }); + }); + + it("passes the configured context and namespace to the action", async () => { + (runKubernetesCliCommand as jest.Mock).mockResolvedValue({ + command: "kubectl --context=lab --namespace=demo get pods", + output: "no resources", + exitCode: 0, + isError: false, + }); + + render(); + runCommand("kubectl get pods"); + + await waitFor(() => + expect(runKubernetesCliCommand).toHaveBeenCalledWith("kubectl get pods", { + context: "lab", + namespace: "demo", + }) + ); + }); + + it("shows the pinned target in the header", () => { + render(); + + expect( + screen.getByText("context: lab, namespace: demo | Allowed commands: oc, kubectl") + ).toBeInTheDocument(); + }); + + it("disables the run button while a command is running", async () => { + let resolveCommand: (value: unknown) => void = () => {}; + (runKubernetesCliCommand as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolveCommand = resolve; + }) + ); + + render(); + runCommand("kubectl version"); + + const button = screen.getByRole("button"); + await waitFor(() => expect(button).toBeDisabled()); + expect(screen.getByText("Running...")).toBeInTheDocument(); + expect( + screen.queryByText("Run oc, kubectl commands against the lab cluster.") + ).not.toBeInTheDocument(); + + resolveCommand({ + command: "kubectl version", + output: "Client Version: 4.22.11", + exitCode: 0, + isError: false, + }); + + expect(await screen.findByText("Client Version: 4.22.11")).toBeInTheDocument(); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.queryByText("Running...")).not.toBeInTheDocument(); + }); + + it("shows the rejection message for a disallowed command", async () => { + (runKubernetesCliCommand as jest.Mock).mockResolvedValue({ + command: "rm -rf /", + output: "Only oc and kubectl commands are allowed.", + exitCode: 1, + isError: true, + }); + + render(); + runCommand("rm -rf /"); + + expect( + await screen.findByText("Only oc and kubectl commands are allowed.") + ).toBeInTheDocument(); + }); + + it("shows an error when the action rejects", async () => { + (runKubernetesCliCommand as jest.Mock).mockRejectedValue(new Error("network down")); + + render(); + runCommand("kubectl get pods"); + + expect(await screen.findByText("network down")).toBeInTheDocument(); + }); + + it("clears the input and keeps prior output for repeated commands", async () => { + (runKubernetesCliCommand as jest.Mock) + .mockResolvedValueOnce({ + command: "kubectl get pods", + output: "first output", + exitCode: 0, + isError: false, + }) + .mockResolvedValueOnce({ + command: "oc whoami", + output: "second output", + exitCode: 0, + isError: false, + }); + + render(); + const input = screen.getByLabelText("Kubernetes shell command") as HTMLInputElement; + + runCommand("kubectl get pods"); + expect(await screen.findByText("first output")).toBeInTheDocument(); + expect(input.value).toBe(""); + + runCommand("oc whoami"); + expect(await screen.findByText("second output")).toBeInTheDocument(); + expect(screen.getByText("first output")).toBeInTheDocument(); + }); + + it("navigates executed commands with the up and down arrows", async () => { + (runKubernetesCliCommand as jest.Mock) + .mockResolvedValueOnce({ + command: "kubectl get pods", + output: "pods", + exitCode: 0, + isError: false, + }) + .mockResolvedValueOnce({ + command: "oc whoami", + output: "admin", + exitCode: 0, + isError: false, + }); + + render(); + const input = screen.getByLabelText("Kubernetes shell command") as HTMLInputElement; + + runCommand("kubectl get pods"); + expect(await screen.findByText("pods")).toBeInTheDocument(); + runCommand("oc whoami"); + expect(await screen.findByText("admin")).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: "ArrowUp" }); + expect(input.value).toBe("oc whoami"); + fireEvent.keyDown(input, { key: "ArrowUp" }); + expect(input.value).toBe("kubectl get pods"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.value).toBe("oc whoami"); + }); + + it("keeps history navigation within its bounds and restores the draft", async () => { + (runKubernetesCliCommand as jest.Mock).mockResolvedValue({ + command: "kubectl get pods", + output: "pods", + exitCode: 0, + isError: false, + }); + + render(); + const input = screen.getByLabelText("Kubernetes shell command") as HTMLInputElement; + + runCommand("kubectl get pods"); + expect(await screen.findByText("pods")).toBeInTheDocument(); + fireEvent.change(input, { target: { value: "kubectl get services" } }); + + fireEvent.keyDown(input, { key: "ArrowUp" }); + expect(input.value).toBe("kubectl get pods"); + fireEvent.keyDown(input, { key: "ArrowUp" }); + expect(input.value).toBe("kubectl get pods"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.value).toBe("kubectl get services"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.value).toBe("kubectl get services"); + }); + + it("does not run an empty command", () => { + render(); + runCommand(" "); + + expect(runKubernetesCliCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/kubernetes-shell.tsx b/src/app/components/kubernetes-shell.tsx new file mode 100644 index 0000000..a460ec3 --- /dev/null +++ b/src/app/components/kubernetes-shell.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; +import { runKubernetesCliCommand } from "@/lib/kubernetes-cli-action"; +import { KUBERNETES_CLI_ALLOWED_COMMANDS, KubernetesCliResult } from "@/lib/kubernetes-cli"; + +type KubernetesShellProps = { + title?: string; + placeholder?: string; + context?: string; + namespace?: string; +}; + +const ALLOWED_COMMAND_LABEL = KUBERNETES_CLI_ALLOWED_COMMANDS.join(", "); + +export function KubernetesShell({ + title = "Kubernetes Shell", + placeholder = "kubectl get pods", + context, + namespace, +}: KubernetesShellProps) { + const [command, setCommand] = useState(""); + const [commandHistory, setCommandHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(null); + const [hasExecutedCommand, setHasExecutedCommand] = useState(false); + const [entries, setEntries] = useState([]); + const [isRunning, setIsRunning] = useState(false); + const outputRef = useRef(null); + const historyDraftRef = useRef(""); + + const targetLabel = [ + context ? `context: ${context}` : null, + namespace ? `namespace: ${namespace}` : null, + ] + .filter((part): part is string => part !== null) + .join(", "); + + useEffect(() => { + if (!outputRef.current) { + return; + } + + outputRef.current.scrollTop = outputRef.current.scrollHeight; + }, [entries, isRunning]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const trimmed = command.trim(); + if (!trimmed || isRunning) { + return; + } + + setCommandHistory((previous) => [...previous, trimmed]); + setHistoryIndex(null); + setHasExecutedCommand(true); + setCommand(""); + setIsRunning(true); + + try { + const result = await runKubernetesCliCommand(trimmed, { context, namespace }); + setEntries((previous) => [...previous, result]); + } catch (error) { + setEntries((previous) => [ + ...previous, + { + command: trimmed, + output: error instanceof Error ? error.message : "Command failed.", + exitCode: 1, + isError: true, + }, + ]); + } finally { + setIsRunning(false); + } + }; + + const handleCommandKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowUp") { + if (commandHistory.length === 0) { + return; + } + + event.preventDefault(); + if (historyIndex === null) { + historyDraftRef.current = command; + } + + const nextIndex = historyIndex === null + ? commandHistory.length - 1 + : Math.max(historyIndex - 1, 0); + setHistoryIndex(nextIndex); + setCommand(commandHistory[nextIndex]); + return; + } + + if (event.key !== "ArrowDown" || historyIndex === null) { + return; + } + + event.preventDefault(); + const nextIndex = historyIndex + 1; + if (nextIndex >= commandHistory.length) { + setHistoryIndex(null); + setCommand(historyDraftRef.current); + return; + } + + setHistoryIndex(nextIndex); + setCommand(commandHistory[nextIndex]); + }; + + return ( +
+
+ {title} + + {targetLabel ? `${targetLabel} | ` : ""}Allowed commands: {ALLOWED_COMMAND_LABEL} + +
+
+ {!hasExecutedCommand && !isRunning ? ( +
+            Run {ALLOWED_COMMAND_LABEL} commands against the lab cluster.
+          
+ ) : ( + entries.map((entry, index) => ( +
+              $ {entry.command}
+              {"\n"}
+              {entry.output}
+            
+ )) + )} + {isRunning ? ( +
+            Running...
+          
+ ) : null} +
+
+ { + setCommand(event.target.value); + setHistoryIndex(null); + }} + onKeyDown={handleCommandKeyDown} + placeholder={hasExecutedCommand ? "" : placeholder} + type="text" + value={command} + /> + +
+
+ ); +} diff --git a/src/app/components/toc.tsx b/src/app/components/toc.tsx index a73abb2..5d2f3b7 100644 --- a/src/app/components/toc.tsx +++ b/src/app/components/toc.tsx @@ -66,6 +66,8 @@ export function ToC() { const headingElements = document.querySelectorAll("h1, h2, h3, h4"); const nextHeadings = collectHeadings(headingElements); + // Headings are rendered by sibling MDX content, so they can only be read after mount. + // eslint-disable-next-line react-hooks/set-state-in-effect setHeadings(nextHeadings); const observer = new IntersectionObserver( diff --git a/src/app/docs/author-docs.mdx b/src/app/docs/author-docs.mdx index 3ec30ee..3fcb7b5 100644 --- a/src/app/docs/author-docs.mdx +++ b/src/app/docs/author-docs.mdx @@ -465,4 +465,62 @@ The **CreateCertificate** provides a tile with a button that will create PEM-for + +
+--- + +## KubernetesShell + +The **KubernetesShell** provides a restricted command line for the lab user and displays the output of each command. + +Only the `oc` and `kubectl` CLIs can be run. Commands are executed on the server without a shell, so shell operators such as `;`, `|`, `&&`, backticks, and redirection are rejected. Quoted arguments are supported. + +The shell retains commands after they are run. Press the Up and Down arrow keys in the command input to navigate the history; Down restores any unfinished command after the newest history entry. Its initial prompt disappears when the first command is submitted. + +| Variable | Description | Required | +|-----------------|--------------------------------------------------------------------|----------| +| **title** | The heading shown above the shell. Defaults to `Kubernetes Shell`. | No | +| **placeholder** | The input placeholder text. Defaults to `kubectl get pods`. | No | +| **context** | Pins commands to a kubeconfig context via `--context`. | No | +| **namespace** | Pins commands to a namespace via `--namespace`. | No | + +Without **context** or **namespace**, commands use the active context of the kubeconfig the server was started with. When set, the flags are inserted directly after the CLI name, so a `--` passthrough such as `kubectl exec pod -- ls` still works. + +If the kubeconfig is missing or the cluster cannot be reached, the shell reports that the cluster is unavailable along with the underlying CLI error, instead of only showing a raw connection failure. + +### Example + +```jsx + +``` + + + +With a custom title and placeholder: + +```jsx + +``` + + + +Pinned to a context and namespace: + +```jsx + +``` + + \ No newline at end of file diff --git a/src/app/lib/docker-lib.ts b/src/app/lib/docker-lib.ts index fc64ca3..dd3279f 100644 --- a/src/app/lib/docker-lib.ts +++ b/src/app/lib/docker-lib.ts @@ -146,19 +146,6 @@ function isContainerNameConflictError(error: unknown): boolean { return /already in use|conflict/i.test(error.message); } -async function ensureExistingContainerRunning( - docker: Docker, - name: string, - deploymentIdentifier?: string | null -): Promise { - const deploymentName = await resolveDeploymentName(name, deploymentIdentifier); - const container = docker.getContainer(deploymentName); - const data = await container.inspect(); - if (data.State?.Status !== InstanceState.Running) { - await container.start(); - } -} - async function removeExistingContainer( docker: Docker, name: string, diff --git a/src/app/lib/use-local-storage.ts b/src/app/lib/use-local-storage.ts index 00e06af..4d90259 100644 --- a/src/app/lib/use-local-storage.ts +++ b/src/app/lib/use-local-storage.ts @@ -33,7 +33,7 @@ function persistStoredValue(key: string, value: T): void { */ const useLocalStorage = (key: string, defaultValue: T): [T, (valueOrFn: T | ((val: T) => T)) => void] => { const defaultValueRef = useRef(defaultValue); - const [localStorageValue, setLocalStorageValue] = useState(defaultValueRef.current) + const [localStorageValue, setLocalStorageValue] = useState(defaultValue) useEffect(() => { if (typeof window === "undefined") { diff --git a/src/lib/check-api.ts b/src/lib/check-api.ts index 5a06da1..d665075 100644 --- a/src/lib/check-api.ts +++ b/src/lib/check-api.ts @@ -157,7 +157,7 @@ export async function checkAPI({ try { console.log(`Calling API Check at: ${url}`) // @ts-expect-error TS2769 - let response = await fetch(url, { mode: "cors", cache: "no-store" }); // url will never be null here. adding a conditional would cause unreachable code here. + const response = await fetch(url, { mode: "cors", cache: "no-store" }); // url will never be null here. adding a conditional would cause unreachable code here. if (response.status != targetStatusCode) { throw new Error(`HTTP error ${response.status}: ${response.statusText}`); diff --git a/src/lib/kubernetes-cli-action.test.ts b/src/lib/kubernetes-cli-action.test.ts new file mode 100644 index 0000000..a84c15d --- /dev/null +++ b/src/lib/kubernetes-cli-action.test.ts @@ -0,0 +1,148 @@ +import { access } from "fs/promises"; +import { runKubernetesCliCommand } from "./kubernetes-cli-action"; +import { CLUSTER_UNAVAILABLE_HINT } from "./kubernetes-cli"; + +const mockExecFile = jest.fn(); + +jest.mock("child_process", () => { + const { promisify: actualPromisify } = jest.requireActual("util"); + const execFile = jest.fn(); + Object.defineProperty(execFile, actualPromisify.custom, { + value: (...args: unknown[]) => mockExecFile(...args), + }); + return { execFile }; +}); + +jest.mock("fs/promises", () => ({ + access: jest.fn(), +})); + +const accessMock = access as jest.MockedFunction; +const originalKubeconfig = process.env.KUBECONFIG; + +describe("runKubernetesCliCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.KUBECONFIG = "/app/.kube/k3s.yaml"; + accessMock.mockResolvedValue(undefined); + }); + + afterAll(() => { + process.env.KUBECONFIG = originalKubeconfig; + }); + + it("runs an allowed command without a shell", async () => { + mockExecFile.mockResolvedValue({ stdout: "pod/nginx", stderr: "" }); + + const result = await runKubernetesCliCommand("kubectl get pods"); + + expect(mockExecFile).toHaveBeenCalledWith( + "kubectl", + ["get", "pods"], + expect.objectContaining({ timeout: expect.any(Number) }) + ); + expect(result).toMatchObject({ output: "pod/nginx", exitCode: 0, isError: false }); + }); + + it("places target flags before user arguments", async () => { + mockExecFile.mockResolvedValue({ stdout: "ok", stderr: "" }); + + const result = await runKubernetesCliCommand("kubectl exec pod -- ls", { + context: "lab", + namespace: "demo", + }); + + expect(mockExecFile).toHaveBeenCalledWith( + "kubectl", + ["--context=lab", "--namespace=demo", "exec", "pod", "--", "ls"], + expect.anything() + ); + expect(result.command).toBe("kubectl --context=lab --namespace=demo exec pod -- ls"); + }); + + it("rejects a disallowed command without executing it", async () => { + const result = await runKubernetesCliCommand("rm -rf /"); + + expect(mockExecFile).not.toHaveBeenCalled(); + expect(result).toMatchObject({ exitCode: 1, isError: true }); + expect(result.output).toBe("Only oc and kubectl commands are allowed."); + }); + + it("rejects an invalid target without executing the command", async () => { + const result = await runKubernetesCliCommand("kubectl get pods", { + namespace: "--all-namespaces", + }); + + expect(mockExecFile).not.toHaveBeenCalled(); + expect(result.output).toContain("Invalid --namespace value"); + }); + + it("reports a missing kubeconfig before running the command", async () => { + accessMock.mockRejectedValue(new Error("ENOENT")); + + const result = await runKubernetesCliCommand("kubectl get pods"); + + expect(mockExecFile).not.toHaveBeenCalled(); + expect(result.output).toContain(CLUSTER_UNAVAILABLE_HINT); + expect(result.output).toContain("Kubeconfig not found at /app/.kube/k3s.yaml."); + }); + + it("accepts a KUBECONFIG list when one path exists", async () => { + process.env.KUBECONFIG = "/missing/one.yaml:/app/.kube/k3s.yaml"; + accessMock.mockImplementation((target) => + target === "/app/.kube/k3s.yaml" ? Promise.resolve(undefined) : Promise.reject(new Error("ENOENT")) + ); + mockExecFile.mockResolvedValue({ stdout: "ok", stderr: "" }); + + const result = await runKubernetesCliCommand("kubectl get pods"); + + expect(result.isError).toBe(false); + }); + + it("skips the kubeconfig check when KUBECONFIG is unset", async () => { + delete process.env.KUBECONFIG; + mockExecFile.mockResolvedValue({ stdout: "ok", stderr: "" }); + + await runKubernetesCliCommand("kubectl get pods"); + + expect(accessMock).not.toHaveBeenCalled(); + expect(mockExecFile).toHaveBeenCalled(); + }); + + it("adds guidance when the cluster is unreachable", async () => { + mockExecFile.mockRejectedValue( + Object.assign(new Error("failed"), { + stderr: "dial tcp: lookup k3s-single-node on 127.0.0.11:53: no such host", + code: 1, + }) + ); + + const result = await runKubernetesCliCommand("kubectl get nodes"); + + expect(result.output).toContain(CLUSTER_UNAVAILABLE_HINT); + expect(result.output).toContain("no such host"); + expect(result.isError).toBe(true); + }); + + it("leaves normal command errors unchanged", async () => { + mockExecFile.mockRejectedValue( + Object.assign(new Error("failed"), { + stderr: 'Error from server (NotFound): pods "missing" not found', + code: 1, + }) + ); + + const result = await runKubernetesCliCommand("kubectl get pod missing"); + + expect(result.output).toBe('Error from server (NotFound): pods "missing" not found'); + expect(result.output).not.toContain(CLUSTER_UNAVAILABLE_HINT); + }); + + it("reports when a command produced no output", async () => { + mockExecFile.mockResolvedValue({ stdout: "", stderr: "" }); + + const result = await runKubernetesCliCommand("kubectl delete pod nginx"); + + expect(result.output).toBe("Command completed with no output."); + }); +}); diff --git a/src/lib/kubernetes-cli-action.ts b/src/lib/kubernetes-cli-action.ts new file mode 100644 index 0000000..0c55711 --- /dev/null +++ b/src/lib/kubernetes-cli-action.ts @@ -0,0 +1,123 @@ +"use server"; + +import { execFile } from "child_process"; +import { access } from "fs/promises"; +import path from "path"; +import { promisify } from "util"; +import { + KUBERNETES_CLI_MAX_OUTPUT_BYTES, + KUBERNETES_CLI_TIMEOUT_MS, + KubernetesCliResult, + KubernetesCliTarget, + buildClusterUnavailableMessage, + buildKubernetesCliTargetArgs, + buildMissingKubeconfigMessage, + isClusterUnavailableOutput, + parseKubernetesCliCommand, +} from "./kubernetes-cli"; + +const execFileAsync = promisify(execFile); + +/** + * Returns the configured KUBECONFIG value when none of its paths exist. + * + * @returns {Promise} The unusable KUBECONFIG value, or null when it is usable or unset. + */ +async function findMissingKubeconfig(): Promise { + const kubeconfig = process.env.KUBECONFIG?.trim(); + if (!kubeconfig) { + return null; + } + + const paths = kubeconfig + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + const found = await Promise.all( + paths.map((entry) => access(entry).then(() => true).catch(() => false)) + ); + + return found.some(Boolean) ? null : kubeconfig; +} + +/** + * Joins process output streams into a single displayable string. + * + * @param {string | undefined} stdout - The standard output content. + * @param {string | undefined} stderr - The standard error content. + * @returns {string} The combined, trimmed output. + */ +function combineOutput(stdout?: string, stderr?: string): string { + return [stdout, stderr] + .filter((chunk): chunk is string => typeof chunk === "string" && chunk.trim().length > 0) + .join("\n") + .trim(); +} + +/** + * Runs an allowed Kubernetes CLI command and returns its combined output. + * + * Commands are executed without a shell, so only `oc` and `kubectl` can be invoked. + * + * @param {string} input - The raw command string entered by the lab user. + * @param {KubernetesCliTarget} target - The context and namespace to pin the command to. + * @returns {Promise} The command result, including output and exit code. + */ +export async function runKubernetesCliCommand( + input: string, + target: KubernetesCliTarget = {} +): Promise { + let command: string; + let args: string[]; + + try { + const parsed = parseKubernetesCliCommand(input); + // Target flags lead the arguments so a trailing `--` passthrough stays intact. + args = [...buildKubernetesCliTargetArgs(target), ...parsed.args]; + command = parsed.command; + } catch (error) { + return { + command: input.trim(), + output: error instanceof Error ? error.message : "Invalid command.", + exitCode: 1, + isError: true, + }; + } + + const displayCommand = [command, ...args].join(" "); + + const missingKubeconfig = await findMissingKubeconfig(); + if (missingKubeconfig !== null) { + return { + command: displayCommand, + output: buildMissingKubeconfigMessage(missingKubeconfig), + exitCode: 1, + isError: true, + }; + } + + try { + const { stdout, stderr } = await execFileAsync(command, args, { + timeout: KUBERNETES_CLI_TIMEOUT_MS, + maxBuffer: KUBERNETES_CLI_MAX_OUTPUT_BYTES, + }); + + return { + command: displayCommand, + output: combineOutput(stdout, stderr) || "Command completed with no output.", + exitCode: 0, + isError: false, + }; + } catch (error) { + const failure = error as Error & { stdout?: string; stderr?: string; code?: number | string }; + const output = combineOutput(failure.stdout, failure.stderr) || failure.message || "Command failed."; + + return { + command: displayCommand, + output: isClusterUnavailableOutput(output) ? buildClusterUnavailableMessage(output) : output, + exitCode: typeof failure.code === "number" ? failure.code : 1, + isError: true, + }; + } +} diff --git a/src/lib/kubernetes-cli.test.ts b/src/lib/kubernetes-cli.test.ts new file mode 100644 index 0000000..c66a32e --- /dev/null +++ b/src/lib/kubernetes-cli.test.ts @@ -0,0 +1,149 @@ +import { + CLUSTER_UNAVAILABLE_HINT, + KUBERNETES_CLI_ALLOWED_COMMANDS, + buildClusterUnavailableMessage, + buildKubernetesCliTargetArgs, + buildMissingKubeconfigMessage, + isClusterUnavailableOutput, + parseKubernetesCliCommand, +} from "./kubernetes-cli"; + +describe("parseKubernetesCliCommand", () => { + it("allows oc and kubectl only", () => { + expect(KUBERNETES_CLI_ALLOWED_COMMANDS).toEqual(["oc", "kubectl"]); + }); + + it("parses a kubectl command with arguments", () => { + expect(parseKubernetesCliCommand("kubectl get pods -n default")).toEqual({ + command: "kubectl", + args: ["get", "pods", "-n", "default"], + }); + }); + + it("parses an oc command with quoted arguments", () => { + expect( + parseKubernetesCliCommand(`oc get pods -o jsonpath='{.items[0].metadata.name}'`) + ).toEqual({ + command: "oc", + args: ["get", "pods", "-o", "jsonpath={.items[0].metadata.name}"], + }); + }); + + it("preserves quoted values that contain spaces", () => { + expect(parseKubernetesCliCommand('kubectl label pod app "my app"')).toEqual({ + command: "kubectl", + args: ["label", "pod", "app", "my app"], + }); + }); + + it("ignores surrounding whitespace", () => { + expect(parseKubernetesCliCommand(" kubectl version ")).toEqual({ + command: "kubectl", + args: ["version"], + }); + }); + + it("rejects an empty command", () => { + expect(() => parseKubernetesCliCommand(" ")).toThrow("Enter a command to run."); + }); + + it.each(["ls -la", "docker ps", "sh", "kubectl.exe get pods"])( + "rejects disallowed command %s", + (input) => { + expect(() => parseKubernetesCliCommand(input)).toThrow( + "Only oc and kubectl commands are allowed." + ); + } + ); + + it.each([ + "kubectl get pods; rm -rf /", + "kubectl get pods | sh", + "kubectl get pods && whoami", + "kubectl get pods > /tmp/out", + "kubectl get pods `whoami`", + "kubectl get pods $(whoami)", + ])("rejects shell composition in %s", (input) => { + expect(() => parseKubernetesCliCommand(input)).toThrow( + /Shell operators are not supported/ + ); + }); + + it("rejects an unterminated quote", () => { + expect(() => parseKubernetesCliCommand(`kubectl get pod "name`)).toThrow( + "Unterminated quote in command." + ); + }); +}); + +describe("buildKubernetesCliTargetArgs", () => { + it("returns no flags when no target is supplied", () => { + expect(buildKubernetesCliTargetArgs()).toEqual([]); + expect(buildKubernetesCliTargetArgs({})).toEqual([]); + }); + + it("builds context and namespace flags", () => { + expect( + buildKubernetesCliTargetArgs({ context: "lab-cluster", namespace: "demo" }) + ).toEqual(["--context=lab-cluster", "--namespace=demo"]); + }); + + it("builds only the supplied flag", () => { + expect(buildKubernetesCliTargetArgs({ namespace: "demo" })).toEqual(["--namespace=demo"]); + }); + + it("ignores blank values", () => { + expect(buildKubernetesCliTargetArgs({ context: " ", namespace: "" })).toEqual([]); + }); + + it("allows OpenShift style context names", () => { + expect( + buildKubernetesCliTargetArgs({ context: "default/api-openshift-example:6443/admin" }) + ).toEqual(["--context=default/api-openshift-example:6443/admin"]); + }); + + it.each(["--all-namespaces", "-n", "demo namespace", "demo;rm", "$demo"])( + "rejects unsafe target value %s", + (value) => { + expect(() => buildKubernetesCliTargetArgs({ namespace: value })).toThrow( + /Invalid --namespace value/ + ); + } + ); +}); + +describe("cluster availability messages", () => { + it.each([ + 'dial tcp: lookup k3s-single-node on 127.0.0.11:53: no such host', + "The connection to the server localhost:8080 was refused", + "Unable to connect to the server: i/o timeout", + "error: no configuration has been provided, try setting KUBERNETES_MASTER", + ])("detects unreachable cluster output: %s", (output) => { + expect(isClusterUnavailableOutput(output)).toBe(true); + }); + + it.each([ + 'Error from server (NotFound): pods "missing" not found', + "error: the server doesn't have a resource type \"widgets\"", + ])("does not treat normal errors as unreachable: %s", (output) => { + expect(isClusterUnavailableOutput(output)).toBe(false); + }); + + it("prefixes the hint to the original output", () => { + const message = buildClusterUnavailableMessage("dial tcp: no such host"); + + expect(message).toContain(CLUSTER_UNAVAILABLE_HINT); + expect(message).toContain("dial tcp: no such host"); + }); + + it("returns only the hint when there is no detail", () => { + expect(buildClusterUnavailableMessage(" ")).toBe(CLUSTER_UNAVAILABLE_HINT); + }); + + it("names the missing kubeconfig path", () => { + const message = buildMissingKubeconfigMessage("/app/.kube/k3s.yaml"); + + expect(message).toContain(CLUSTER_UNAVAILABLE_HINT); + expect(message).toContain("Kubeconfig not found at /app/.kube/k3s.yaml."); + }); +}); diff --git a/src/lib/kubernetes-cli.ts b/src/lib/kubernetes-cli.ts new file mode 100644 index 0000000..61cff27 --- /dev/null +++ b/src/lib/kubernetes-cli.ts @@ -0,0 +1,206 @@ +/** + * Command line interfaces the lab shell is allowed to run. + */ +export const KUBERNETES_CLI_ALLOWED_COMMANDS = ["oc", "kubectl"] as const; + +/** + * Maximum time a single CLI invocation may run before it is terminated. + */ +export const KUBERNETES_CLI_TIMEOUT_MS = 30_000; + +/** + * Maximum number of output bytes captured from a single CLI invocation. + */ +export const KUBERNETES_CLI_MAX_OUTPUT_BYTES = 1_000_000; + +/** + * Characters that are rejected because they imply shell composition rather than a single CLI call. + */ +const DISALLOWED_SHELL_CHARACTERS = [";", "|", "&", "`", "$", ">", "<", "\n", "\r"]; + +export type KubernetesCliName = (typeof KUBERNETES_CLI_ALLOWED_COMMANDS)[number]; + +export type KubernetesCliTarget = { + context?: string; + namespace?: string; +}; + +export type ParsedKubernetesCliCommand = { + command: KubernetesCliName; + args: string[]; +}; + +export type KubernetesCliResult = { + command: string; + output: string; + exitCode: number; + isError: boolean; +}; + +/** + * Splits a command string into tokens, honoring single and double quoted segments. + * + * @param {string} input - The raw command string. + * @returns {string[]} The parsed tokens. + * @throws {Error} When a quote is left unterminated or a shell control character is used. + */ +function tokenizeCommand(input: string): string[] { + const tokens: string[] = []; + let current = ""; + let hasCurrent = false; + let quoteCharacter: '"' | "'" | null = null; + + for (const character of input) { + if (quoteCharacter !== null) { + if (character === quoteCharacter) { + quoteCharacter = null; + continue; + } + current += character; + continue; + } + + if (character === '"' || character === "'") { + quoteCharacter = character; + hasCurrent = true; + continue; + } + + if (DISALLOWED_SHELL_CHARACTERS.includes(character)) { + throw new Error( + `Shell operators are not supported. Remove "${character}" and run a single command.` + ); + } + + if (character === " " || character === "\t") { + if (hasCurrent) { + tokens.push(current); + current = ""; + hasCurrent = false; + } + continue; + } + + current += character; + hasCurrent = true; + } + + if (quoteCharacter !== null) { + throw new Error("Unterminated quote in command."); + } + + if (hasCurrent) { + tokens.push(current); + } + + return tokens; +} + +/** + * Validates that a command string invokes an allowed Kubernetes CLI and parses its arguments. + * + * @param {string} input - The raw command string entered by the lab user. + * @returns {ParsedKubernetesCliCommand} The allowed command name and its arguments. + * @throws {Error} When the command is empty or is not an allowed CLI. + */ +export function parseKubernetesCliCommand(input: string): ParsedKubernetesCliCommand { + const tokens = tokenizeCommand(input.trim()); + + if (tokens.length === 0) { + throw new Error("Enter a command to run."); + } + + const [command, ...args] = tokens; + const allowedCommand = KUBERNETES_CLI_ALLOWED_COMMANDS.find( + (candidate) => candidate === command + ); + + if (allowedCommand === undefined) { + throw new Error( + `Only ${KUBERNETES_CLI_ALLOWED_COMMANDS.join(" and ")} commands are allowed.` + ); + } + + return { command: allowedCommand, args }; +} + +// Rejects values that could be read as additional flags rather than a target name. +const TARGET_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@-]*$/; + +/** + * Builds the global flags that pin a command to a specific cluster context and namespace. + * + * @param {KubernetesCliTarget} target - The context and namespace supplied by the lab author. + * @returns {string[]} The flags to place directly after the CLI name. + * @throws {Error} When a supplied value is not a valid context or namespace name. + */ +export function buildKubernetesCliTargetArgs(target: KubernetesCliTarget = {}): string[] { + const flagValues: ReadonlyArray = [ + ["--context", target.context], + ["--namespace", target.namespace], + ]; + + const args: string[] = []; + + for (const [flag, value] of flagValues) { + const trimmed = value?.trim() ?? ""; + if (trimmed.length === 0) { + continue; + } + + if (!TARGET_VALUE_PATTERN.test(trimmed)) { + throw new Error(`Invalid ${flag} value: "${trimmed}".`); + } + + args.push(`${flag}=${trimmed}`); + } + + return args; +} + +/** + * Guidance shown when the configured cluster cannot be reached. + */ +export const CLUSTER_UNAVAILABLE_HINT = + "Kubernetes cluster unavailable. Start the peer K3s cluster with the k3s Compose profile, or point KUBECONFIG at a reachable cluster."; + +const CLUSTER_UNAVAILABLE_PATTERNS = [ + /no such host/i, + /dial tcp/i, + /connection refused/i, + /i\/o timeout/i, + /unable to connect to the server/i, + /no configuration has been provided/i, + /the connection to the server .* was refused/i, +]; + +/** + * Reports whether CLI output indicates the cluster could not be reached. + * + * @param {string} output - The combined CLI output. + * @returns {boolean} True when the failure looks like a connectivity problem. + */ +export function isClusterUnavailableOutput(output: string): boolean { + return CLUSTER_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(output)); +} + +/** + * Prefixes connectivity failures with actionable guidance. + * + * @param {string} detail - The original CLI output. + * @returns {string} The guidance followed by the original output. + */ +export function buildClusterUnavailableMessage(detail: string): string { + const trimmed = detail.trim(); + return trimmed.length > 0 ? `${CLUSTER_UNAVAILABLE_HINT}\n\n${trimmed}` : CLUSTER_UNAVAILABLE_HINT; +} + +/** + * Builds the message shown when the configured kubeconfig file does not exist. + * + * @param {string} kubeconfigPath - The configured KUBECONFIG value. + * @returns {string} The guidance including the missing path. + */ +export function buildMissingKubeconfigMessage(kubeconfigPath: string): string { + return `${CLUSTER_UNAVAILABLE_HINT}\n\nKubeconfig not found at ${kubeconfigPath}.`; +} diff --git a/src/lib/mdxComponents.tsx b/src/lib/mdxComponents.tsx index defa263..64fac9b 100644 --- a/src/lib/mdxComponents.tsx +++ b/src/lib/mdxComponents.tsx @@ -8,6 +8,7 @@ import { DockerContainer } from "@/app/components/docker-container"; import { GetVariable } from "@/app/components/get-variable"; import ImageModalClient from "@/app/components/image-modal-client"; import { InputVariable } from "@/app/components/input-var"; +import { KubernetesShell } from "@/app/components/kubernetes-shell"; import { SetVariable } from "@/app/components/set-variable"; import { CreateCertificate } from "@/app/components/create-certificate"; import UDFComponent from "@/app/components/udf-component"; @@ -35,6 +36,7 @@ const MDXComponents = { DockerContainer, GetVariable, InputVariable, + KubernetesShell, SetVariable, UDFComponent, UdfDeploymentMetadata,