From 20fe6358fc8bb0fdfd6719611827256c9ddbb02c Mon Sep 17 00:00:00 2001 From: Minal Pradeep Makwana Date: Wed, 1 Jul 2026 18:47:32 +0530 Subject: [PATCH] add LVMS toolset for storage diagnostics --- README.md | 15 + docs/configuration.md | 1 + evals/claude-code/eval.yaml | 11 + evals/openai-agent/eval.yaml | 11 + evals/tasks/lvms/README.md | 57 ++ evals/tasks/lvms/check-capacity/task.yaml | 68 ++ .../lvms/check-lvmcluster-status/task.yaml | 81 ++ .../check-thin-pool-utilization/task.yaml | 52 ++ .../lvms/diagnose-disk-not-used/task.yaml | 54 ++ .../lvms/diagnose-no-space-left/task.yaml | 46 ++ evals/tasks/lvms/forced-cleanup/task.yaml | 89 ++ .../lvms/list-node-block-devices/task.yaml | 43 + .../lvms/show-lvm-physical-volumes/task.yaml | 43 + evals/tasks/lvms/show-volume-groups/task.yaml | 42 + .../tasks/lvms/troubleshoot-prompt/task.yaml | 46 ++ .../lvms/troubleshoot-stuck-pvc/task.yaml | 78 ++ internal/tools/update-readme/main.go | 1 + pkg/mcp/modules.go | 1 + pkg/mcp/testdata/toolsets-lvms-tools.json | 1 + pkg/mcp/toolsets_test.go | 2 + pkg/toolsets/lvms/lvms_capacity.go | 350 ++++++++ pkg/toolsets/lvms/lvms_troubleshoot.go | 760 ++++++++++++++++++ pkg/toolsets/lvms/toolset.go | 48 ++ pkg/toolsets/lvms/toolset_test.go | 81 ++ 24 files changed, 1981 insertions(+) create mode 100644 evals/tasks/lvms/README.md create mode 100644 evals/tasks/lvms/check-capacity/task.yaml create mode 100644 evals/tasks/lvms/check-lvmcluster-status/task.yaml create mode 100644 evals/tasks/lvms/check-thin-pool-utilization/task.yaml create mode 100644 evals/tasks/lvms/diagnose-disk-not-used/task.yaml create mode 100644 evals/tasks/lvms/diagnose-no-space-left/task.yaml create mode 100644 evals/tasks/lvms/forced-cleanup/task.yaml create mode 100644 evals/tasks/lvms/list-node-block-devices/task.yaml create mode 100644 evals/tasks/lvms/show-lvm-physical-volumes/task.yaml create mode 100644 evals/tasks/lvms/show-volume-groups/task.yaml create mode 100644 evals/tasks/lvms/troubleshoot-prompt/task.yaml create mode 100644 evals/tasks/lvms/troubleshoot-stuck-pvc/task.yaml create mode 100644 pkg/mcp/testdata/toolsets-lvms-tools.json create mode 100644 pkg/toolsets/lvms/lvms_capacity.go create mode 100644 pkg/toolsets/lvms/lvms_troubleshoot.go create mode 100644 pkg/toolsets/lvms/toolset.go create mode 100644 pkg/toolsets/lvms/toolset_test.go diff --git a/README.md b/README.md index f1f968130..1a0b93315 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ and only needed for the project-specific scenarios noted. | [Kiali](https://kiali.io) | `kiali` | 16 | | [Kubernetes](https://kubernetes.io) | - | 32 | | [KubeVirt](https://kubevirt.io) | `kubevirt`, `tekton` | 19 | +| [LVMS](https://github.com/openshift/lvm-operator) | `lvms` | 11 | | [Tekton](https://tekton.dev) | `tekton` | 9 | @@ -292,6 +293,7 @@ The following sets of tools are available (toolsets marked with ✓ in the Defau | kcp | Manage kcp workspaces and multi-tenancy features | | | kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | | kubevirt | KubeVirt virtual machine management tools, check the [KubeVirt documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/kubevirt.md) for more details. | | +| lvms | LVMS (Logical Volume Manager Storage) troubleshooting prompts for diagnosing storage issues | | | netobserv | Network observability tools backed by the NetObserv console plugin API (flows, metrics, export). Check the [NetObserv documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/NETOBSERV.md) for more details. | | | tekton | Tekton pipeline management tools for Pipelines, PipelineRuns, Tasks, and TaskRuns. | | @@ -498,6 +500,19 @@ In case multi-cluster support is enabled (default) and you have access to multip
+lvms + +- **lvms-troubleshoot** - Diagnose LVMS storage issues by gathering LVMCluster status, volume group health, and node-level LVM data with interpretation of domain-specific fields + - `namespace` (`string`) - The LVMS namespace (default: openshift-lvm-storage) + - `node` (`string`) - Specific node to inspect (default: all nodes with LVMS) + +- **lvms-capacity** - Check LVMS storage capacity across all nodes including available space per node, PVC usage, and capacity warnings + - `namespace` (`string`) - The LVMS namespace (default: openshift-lvm-storage) + +
+ +
+ netobserv - **netobserv_list_flows** - Lists NetObserv network flow records from Loki. Use when investigating traffic between workloads, IPs, ports, or protocols in a namespace or time window. diff --git a/docs/configuration.md b/docs/configuration.md index aef5b6165..4ad1d3e6e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -318,6 +318,7 @@ Toolsets group related tools together. Enable only the toolsets you need to redu | kcp | Manage kcp workspaces and multi-tenancy features | | | kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | | kubevirt | KubeVirt virtual machine management tools, check the [KubeVirt documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/kubevirt.md) for more details. | | +| lvms | LVMS (Logical Volume Manager Storage) troubleshooting prompts for diagnosing storage issues | | | netobserv | Network observability tools backed by the NetObserv console plugin API (flows, metrics, export). Check the [NetObserv documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/NETOBSERV.md) for more details. | | | tekton | Tekton pipeline management tools for Pipelines, PipelineRuns, Tasks, and TaskRuns. | | diff --git a/evals/claude-code/eval.yaml b/evals/claude-code/eval.yaml index 8fe931c9c..8a9f4137e 100644 --- a/evals/claude-code/eval.yaml +++ b/evals/claude-code/eval.yaml @@ -84,3 +84,14 @@ config: toolPattern: "netobserv_.*" minToolCalls: 1 maxToolCalls: 10 + # LVMS tasks - requires LVMS operator on cluster + # LVMS is a prompts-only toolset; uses core tools for K8s queries + - glob: ../tasks/lvms/*/*.yaml + labelSelector: + suite: lvms + assertions: + toolsUsed: + - server: kubernetes + toolPattern: ".*" + minToolCalls: 1 + maxToolCalls: 20 diff --git a/evals/openai-agent/eval.yaml b/evals/openai-agent/eval.yaml index ecc61a16b..ba98db546 100644 --- a/evals/openai-agent/eval.yaml +++ b/evals/openai-agent/eval.yaml @@ -82,3 +82,14 @@ config: toolPattern: "netobserv_.*" minToolCalls: 1 maxToolCalls: 10 + # LVMS tasks - requires LVMS operator on cluster + # LVMS is a prompts-only toolset; uses core tools for K8s queries + - glob: ../tasks/lvms/*/*.yaml + labelSelector: + suite: lvms + assertions: + toolsUsed: + - server: kubernetes + toolPattern: ".*" + minToolCalls: 1 + maxToolCalls: 20 diff --git a/evals/tasks/lvms/README.md b/evals/tasks/lvms/README.md new file mode 100644 index 000000000..c60471654 --- /dev/null +++ b/evals/tasks/lvms/README.md @@ -0,0 +1,57 @@ +# LVMS Task Library + +Evaluation tasks for the LVMS (Logical Volume Manager Storage) toolset. These tasks exercise storage troubleshooting workflows on OpenShift clusters using LVMS. + +## Prerequisites + +- LVMS operator installed on the cluster +- LVMCluster CR configured (typically in `openshift-lvm-storage` namespace) +- Available block devices on nodes for volume group creation + +## Toolset Design + +LVMS is a **prompts-only toolset** — it provides no dedicated tools. This design follows the toolset-design principle that CRUD operations on Kubernetes resources should use the core toolset's generic tools (`resources_list`, `resources_get`, etc.). + +### What the LVMS toolset provides: + +- **`lvms-troubleshoot` prompt** — Pre-fetches and presents: + - LVMCluster status and device class configuration + - LVMVolumeGroupNodeStatus per node + - vg-manager pod health and logs + - **Node-level LVM data** (vgs, lvs, pvs output via debug pods) + - Events in the LVMS namespace + - Domain knowledge (vg_attr/lv_attr field interpretation) + +### What eval tasks use: + +| Tool | Source | Purpose | +|------|--------|---------| +| `resources_list` / `resources_get` | core | Query LVMS CRDs (LVMCluster, LVMVolumeGroup, etc.) | +| `nodes_debug_exec` | cluster-diagnostics | Run LVM commands on nodes (vgs, lvs, pvs, lsblk) | +| `lvms-troubleshoot` prompt | lvms | Guided diagnostics with pre-fetched data | + +## Task Categories + +| Task | Difficulty | Description | +|------|------------|-------------| +| check-lvmcluster-status | Easy | Verify LVMCluster health and device class configuration | +| check-capacity | Medium | Analyze thin pool utilization and storage capacity | +| check-thin-pool-utilization | Medium | Check thin pool data% and metadata% thresholds | +| list-node-block-devices | Easy | List available block devices on nodes | +| show-volume-groups | Easy | Display volume group status | +| show-lvm-physical-volumes | Easy | Display physical volume status | +| diagnose-no-space-left | Hard | Diagnose thin pool exhaustion issues | +| diagnose-disk-not-used | Hard | Investigate why a disk isn't being used by LVMS | +| troubleshoot-stuck-pvc | Hard | Diagnose why a PVC is stuck in Pending state | +| troubleshoot-prompt | Medium | Use the lvms-troubleshoot prompt for guided diagnosis | +| forced-cleanup | Hard | Perform forced cleanup of stuck LVMS resources | + +## Running Tasks + +```bash +# Run all LVMS tasks +mcpchecker check evals/claude-code/eval.yaml --label-selector suite=lvms + +# Run specific difficulty level +mcpchecker check evals/claude-code/eval.yaml --label-selector suite=lvms,difficulty=hard +``` diff --git a/evals/tasks/lvms/check-capacity/task.yaml b/evals/tasks/lvms/check-capacity/task.yaml new file mode 100644 index 000000000..46dd52a1a --- /dev/null +++ b/evals/tasks/lvms/check-capacity/task.yaml @@ -0,0 +1,68 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "check-lvms-capacity" + difficulty: medium + description: "Check LVMS storage capacity and thin pool utilization across nodes" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + # Verify LVMS is installed + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + # Check if an LVMCluster exists and is ready + CLUSTER=$(kubectl get lvmclusters -n "$NS" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -z "$CLUSTER" ]; then + echo "No LVMCluster found - LVMS must be configured" + exit 1 + fi + + STATE=$(kubectl get lvmcluster "$CLUSTER" -n "$NS" -o jsonpath='{.status.state}' 2>/dev/null) + if [ "$STATE" != "Ready" ]; then + echo "LVMCluster '$CLUSTER' is not ready (state: $STATE)" + exit 1 + fi + + echo "LVMS is configured with LVMCluster: $CLUSTER" + verify: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + echo "=== Verification: Checking agent capacity analysis ===" + + # List node statuses + kubectl get lvmvolumegroupnodestatuses -n "$NS" -o custom-columns='NODE:.metadata.name,STATUS:.status' 2>/dev/null || true + + echo "" + echo "=== Capacity Eval Complete ===" + echo "The agent should have:" + echo " 1. Queried LVMS volume group status across nodes" + echo " 2. Reported per-node storage capacity (total, allocatable)" + echo " 3. Identified any nodes with low available space" + echo " 4. Provided capacity planning recommendations if applicable" + echo "" + + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + # No cleanup needed - read-only operation + exit 0 + prompt: + inline: |- + We're planning to deploy several new applications that will need persistent storage. + How much LVMS storage capacity do we have available across the cluster, and are there + any nodes running low on space? diff --git a/evals/tasks/lvms/check-lvmcluster-status/task.yaml b/evals/tasks/lvms/check-lvmcluster-status/task.yaml new file mode 100644 index 000000000..9004f29b6 --- /dev/null +++ b/evals/tasks/lvms/check-lvmcluster-status/task.yaml @@ -0,0 +1,81 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "check-lvms-cluster-status" + difficulty: easy + description: "Check the status of the LVMCluster and verify it is healthy" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + # Verify LVMS is installed + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + # Check if an LVMCluster exists + if [ -z "$(kubectl get lvmclusters -n "$NS" -o name 2>/dev/null)" ]; then + echo "No LVMCluster found in $NS - creating test cluster" + cat > /tmp/lvmcluster.yaml << 'EOF' + apiVersion: lvm.topolvm.io/v1alpha1 + kind: LVMCluster + metadata: + name: eval-lvmcluster + namespace: openshift-lvm-storage + spec: + storage: + deviceClasses: + - name: vg1 + default: true + fstype: xfs + thinPoolConfig: + name: thin-pool-1 + sizePercent: 90 + overprovisionRatio: 10 + EOF + kubectl apply -f /tmp/lvmcluster.yaml + rm -f /tmp/lvmcluster.yaml + echo "Created eval-lvmcluster - waiting for reconciliation" + sleep 30 + fi + verify: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + echo "=== Verification: Checking agent findings ===" + + # The agent should have used list_lvmclusters or describe_lvmcluster + # and reported the cluster status + + CLUSTER=$(kubectl get lvmclusters -n "$NS" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -n "$CLUSTER" ]; then + STATE=$(kubectl get lvmcluster "$CLUSTER" -n "$NS" -o jsonpath='{.status.state}' 2>/dev/null || echo "Unknown") + READY=$(kubectl get lvmcluster "$CLUSTER" -n "$NS" -o jsonpath='{.status.ready}' 2>/dev/null || echo "Unknown") + echo "LVMCluster '$CLUSTER' state: $STATE, ready: $READY" + fi + + echo "" + echo "=== Eval Complete ===" + echo "The agent should have:" + echo " 1. Queried the LVMCluster resource" + echo " 2. Reported the cluster name, state, and ready status" + echo " 3. Listed any device classes configured" + + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + # Only clean up the eval cluster if we created it + kubectl delete lvmcluster eval-lvmcluster -n openshift-lvm-storage --ignore-not-found + prompt: + inline: Is LVMS storage configured and working properly on this cluster? What storage options are available? diff --git a/evals/tasks/lvms/check-thin-pool-utilization/task.yaml b/evals/tasks/lvms/check-thin-pool-utilization/task.yaml new file mode 100644 index 000000000..f28797aa3 --- /dev/null +++ b/evals/tasks/lvms/check-thin-pool-utilization/task.yaml @@ -0,0 +1,52 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "check-thin-pool-utilization" + difficulty: medium + description: "Check actual thin pool data and metadata utilization on nodes" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + # Verify LVMS is installed and ready + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + CLUSTER=$(kubectl get lvmclusters -n "$NS" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -z "$CLUSTER" ]; then + echo "No LVMCluster found - LVMS must be configured" + exit 1 + fi + + echo "LVMS is configured. This task requires checking LIVE thin pool utilization." + verify: + inline: |- + #!/usr/bin/env bash + echo "=== Verification ===" + echo "The agent should have:" + echo " 1. Run 'lvs' command on the node to get thin pool data" + echo " 2. Reported data_percent and metadata_percent for thin pools" + echo " 3. This CANNOT be done with resources_list/resources_get" + echo "" + echo "If the agent only queried LVMVolumeGroupNodeStatus CRD," + echo "it did NOT get live thin pool utilization - that requires lvs on node." + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + exit 0 + prompt: + inline: |- + What is the current thin pool utilization on each node? I need the actual + data_percent and metadata_percent values from the LVM thin pools, not just + the Kubernetes CRD status. diff --git a/evals/tasks/lvms/diagnose-disk-not-used/task.yaml b/evals/tasks/lvms/diagnose-disk-not-used/task.yaml new file mode 100644 index 000000000..a11ccb976 --- /dev/null +++ b/evals/tasks/lvms/diagnose-disk-not-used/task.yaml @@ -0,0 +1,54 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "diagnose-disk-not-used" + difficulty: hard + description: "Diagnose why a specific disk is not being used by LVMS" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + # Verify LVMS is installed + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + echo "LVMS installed. This task tests disk filtering knowledge." + echo "" + echo "The agent must know LVMS device filtering rules:" + echo "- Read-only devices are excluded" + echo "- Devices with children (partitions) are excluded" + echo "- Devices with reserved partition labels are excluded" + echo "- LVM2_member fstype devices are excluded unless explicitly listed" + echo "- Loop devices in use by Kubernetes are excluded" + verify: + inline: |- + #!/usr/bin/env bash + echo "=== Verification ===" + echo "The agent should have:" + echo " 1. Identified the node where the disk exists" + echo " 2. Run lsblk to inspect disk properties" + echo " 3. Checked LVMCluster deviceSelector configuration" + echo " 4. Identified which LVMS filtering rule excludes the disk" + echo " 5. Suggested how to make the disk available (if possible)" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + exit 0 + prompt: + inline: |- + A user reports that /dev/sdb on node worker-1 is not being used by LVMS + even though they expected it to be available for storage. + + Investigate why this disk is not being used by LVMS and explain + the reason to the user. diff --git a/evals/tasks/lvms/diagnose-no-space-left/task.yaml b/evals/tasks/lvms/diagnose-no-space-left/task.yaml new file mode 100644 index 000000000..30831b3f6 --- /dev/null +++ b/evals/tasks/lvms/diagnose-no-space-left/task.yaml @@ -0,0 +1,46 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "diagnose-no-space-left" + difficulty: hard + description: "Diagnose why a workload is getting 'no space left on device' errors" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + echo "LVMS installed. This is a hard diagnostic task." + verify: + inline: |- + #!/usr/bin/env bash + echo "=== Verification ===" + echo "The agent should have:" + echo " 1. Checked thin pool utilization (requires lvs on node)" + echo " 2. Checked VG free space (requires vgs on node)" + echo " 3. Correlated with PVC/LV sizes" + echo " 4. Identified if thin pool is exhausted" + echo "" + echo "This task REQUIRES node-level LVM commands to diagnose properly." + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + exit 0 + prompt: + inline: |- + A workload using LVMS storage is reporting "no space left on device" errors, + but the PVC shows it still has capacity. This might be a thin pool exhaustion + issue. Can you check the actual thin pool and volume group utilization to + diagnose what's happening? diff --git a/evals/tasks/lvms/forced-cleanup/task.yaml b/evals/tasks/lvms/forced-cleanup/task.yaml new file mode 100644 index 000000000..67963d9ed --- /dev/null +++ b/evals/tasks/lvms/forced-cleanup/task.yaml @@ -0,0 +1,89 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "lvms-forced-cleanup" + difficulty: hard + description: "Perform LVMS forced cleanup in correct order with finalizer handling" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + # Verify LVMS is installed + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + # Create test resources that simulate stuck LVMS state + # These will have finalizers that block deletion + + # Create a LogicalVolume with a finalizer (cluster-scoped resource, no namespace) + cat > /tmp/logicalvolume.yaml << 'EOF' + apiVersion: topolvm.io/v1 + kind: LogicalVolume + metadata: + name: stuck-lv-test + finalizers: + - topolvm.io/logicalvolume + spec: + name: stuck-lv-test + nodeName: test-node + size: 1Gi + deviceClass: vg1 + EOF + kubectl apply -f /tmp/logicalvolume.yaml + rm -f /tmp/logicalvolume.yaml + + echo "Created stuck LogicalVolume with finalizer" + echo "Agent must remove finalizer before deletion" + sleep 2 + verify: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + echo "=== Verification: Checking forced cleanup was performed correctly ===" + + # Check if the LogicalVolume was removed (cluster-scoped, no namespace) + if kubectl get logicalvolume stuck-lv-test > /dev/null 2>&1; then + echo "FAIL: LogicalVolume stuck-lv-test still exists" + echo "Agent should have:" + echo " 1. Patched to remove finalizer: kubectl patch logicalvolume stuck-lv-test -p '{\"metadata\":{\"finalizers\":[]}}' --type=merge" + echo " 2. Deleted the resource: kubectl delete logicalvolume stuck-lv-test" + exit 1 + fi + + echo "SUCCESS: LogicalVolume stuck-lv-test was properly cleaned up" + echo "" + echo "The agent correctly:" + echo " 1. Identified the resource had a finalizer blocking deletion" + echo " 2. Removed the finalizer before attempting delete" + echo " 3. Deleted the resource" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + # Force remove in case test failed (cluster-scoped, no namespace) + kubectl patch logicalvolume stuck-lv-test -p '{"metadata":{"finalizers":[]}}' --type=merge 2>/dev/null || true + kubectl delete logicalvolume stuck-lv-test --ignore-not-found 2>/dev/null || true + prompt: + inline: |- + There is a stuck LogicalVolume named "stuck-lv-test" (cluster-scoped resource, no namespace) + that cannot be deleted normally because it has a finalizer. + + First, call the lvms-troubleshoot prompt to get the LVMS forced cleanup procedure. + Then use the MCP tools (resources_create_or_update to remove finalizers, resources_delete to delete) + to perform the cleanup: + 1. Remove the finalizer that is blocking deletion + 2. Delete the resource + + Do NOT use bash/kubectl commands - use the MCP tools only. + Report what you did and confirm the resource was successfully removed. diff --git a/evals/tasks/lvms/list-node-block-devices/task.yaml b/evals/tasks/lvms/list-node-block-devices/task.yaml new file mode 100644 index 000000000..b40aa4bc1 --- /dev/null +++ b/evals/tasks/lvms/list-node-block-devices/task.yaml @@ -0,0 +1,43 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "list-node-block-devices" + difficulty: medium + description: "List block devices on a node to see what's available for LVMS" +steps: + setup: + inline: |- + #!/usr/bin/env bash + # Get a node name for testing + NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -z "$NODE" ]; then + echo "No nodes found" + exit 1 + fi + echo "Test node: $NODE" + verify: + inline: |- + #!/usr/bin/env bash + echo "=== Verification ===" + echo "The agent should have:" + echo " 1. Run 'lsblk' command on the node" + echo " 2. Shown disk devices, partitions, sizes" + echo " 3. This CANNOT be done with Kubernetes API - requires node access" + echo "" + echo "If the agent only queried Kubernetes resources, it FAILED." + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + exit 0 + prompt: + inline: |- + We want to add more storage capacity to LVMS. What unused disks or + partitions are available on the cluster nodes that could potentially + be added to the volume groups? diff --git a/evals/tasks/lvms/show-lvm-physical-volumes/task.yaml b/evals/tasks/lvms/show-lvm-physical-volumes/task.yaml new file mode 100644 index 000000000..819525972 --- /dev/null +++ b/evals/tasks/lvms/show-lvm-physical-volumes/task.yaml @@ -0,0 +1,43 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "show-lvm-physical-volumes" + difficulty: medium + description: "Show LVM physical volumes on nodes using pvs command" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + # Verify LVMS is installed + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + echo "LVMS installed. This task requires running pvs on nodes." + verify: + inline: |- + #!/usr/bin/env bash + echo "=== Verification ===" + echo "The agent should have:" + echo " 1. Run 'pvs' command on the node(s)" + echo " 2. Shown PV name, VG name, size, free space" + echo " 3. This requires node debug access - not available via K8s API" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + exit 0 + prompt: + inline: |- + I'm trying to understand how our LVMS storage is set up. Which physical + devices are being used for LVM storage, and how much space does each + device contribute to the volume groups? diff --git a/evals/tasks/lvms/show-volume-groups/task.yaml b/evals/tasks/lvms/show-volume-groups/task.yaml new file mode 100644 index 000000000..f19a64097 --- /dev/null +++ b/evals/tasks/lvms/show-volume-groups/task.yaml @@ -0,0 +1,42 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "show-volume-groups" + difficulty: medium + description: "Show LVM volume groups on nodes using vgs command" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + echo "LVMS installed. This task requires running vgs on nodes." + verify: + inline: |- + #!/usr/bin/env bash + echo "=== Verification ===" + echo "The agent should have:" + echo " 1. Run 'vgs' command on the node(s)" + echo " 2. Shown VG name, PV count, LV count, size, free space" + echo " 3. This requires node debug access" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + exit 0 + prompt: + inline: |- + How is LVMS organizing storage on each node? I want to understand the + volume group layout - total capacity, how much is free, and how many + logical volumes exist in each VG. diff --git a/evals/tasks/lvms/troubleshoot-prompt/task.yaml b/evals/tasks/lvms/troubleshoot-prompt/task.yaml new file mode 100644 index 000000000..c4466a6c8 --- /dev/null +++ b/evals/tasks/lvms/troubleshoot-prompt/task.yaml @@ -0,0 +1,46 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "lvms-troubleshoot-prompt" + difficulty: medium + description: "Use the lvms-troubleshoot prompt to diagnose LVMS storage health" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="openshift-lvm-storage" + + # Verify LVMS is installed + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + echo "LVMS installed. Testing lvms-troubleshoot prompt." + verify: + inline: |- + #!/usr/bin/env bash + echo "=== Verification ===" + echo "The agent should have:" + echo " 1. Used the lvms-troubleshoot prompt" + echo " 2. Received pre-fetched LVMCluster status, node statuses, and pod info" + echo " 3. Analyzed vg_attr and other LVMS-specific fields" + echo " 4. Reported findings on cluster health" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + exit 0 + prompt: + inline: |- + Use the lvms-troubleshoot prompt to check the health of LVMS storage on this cluster. + Report your findings including: + - LVMCluster state + - Volume group health on each node + - Any issues or warnings detected diff --git a/evals/tasks/lvms/troubleshoot-stuck-pvc/task.yaml b/evals/tasks/lvms/troubleshoot-stuck-pvc/task.yaml new file mode 100644 index 000000000..3ca545ab1 --- /dev/null +++ b/evals/tasks/lvms/troubleshoot-stuck-pvc/task.yaml @@ -0,0 +1,78 @@ +kind: Task +metadata: + labels: + suite: lvms + project: lvms + requires: lvms + annotations: + project-name: "LVMS" + project-url: "https://github.com/openshift/lvm-operator" + name: "troubleshoot-lvms-stuck-pvc" + difficulty: hard + description: "Diagnose why a PVC using LVMS storage is stuck in Pending state" +steps: + setup: + inline: |- + #!/usr/bin/env bash + NS="lvms-eval-app" + + # Verify LVMS is installed + if ! kubectl get crd lvmclusters.lvm.topolvm.io > /dev/null 2>&1; then + echo "LVMS CRD not found - LVMS must be installed" + exit 1 + fi + + # Create test namespace + kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - + + # Create a PVC that will be stuck (requesting impossibly large storage) + cat > /tmp/stuck-pvc.yaml << 'EOF' + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: stuck-pvc + namespace: lvms-eval-app + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 500Ti + storageClassName: lvms-vg1 + EOF + kubectl apply -f /tmp/stuck-pvc.yaml + rm -f /tmp/stuck-pvc.yaml + + echo "Created PVC requesting 500Ti - this should be stuck pending" + sleep 10 + verify: + inline: |- + #!/usr/bin/env bash + NS="lvms-eval-app" + + echo "=== Verification: Checking if agent diagnosed the issue ===" + + # Check PVC status + PHASE=$(kubectl get pvc stuck-pvc -n "$NS" -o jsonpath='{.status.phase}' 2>/dev/null || echo "Unknown") + echo "PVC phase: $PHASE" + + echo "" + echo "=== Troubleshooting Eval Complete ===" + echo "The agent should have:" + echo " 1. Investigated the PVC and its storage class" + echo " 2. Checked LVMS cluster status and node capacity" + echo " 3. Identified insufficient capacity as the root cause (500Ti requested)" + echo " 4. Recommended action (reduce PVC size or add storage capacity)" + echo "" + + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + kubectl delete pvc stuck-pvc -n lvms-eval-app --ignore-not-found + kubectl delete namespace lvms-eval-app --ignore-not-found + prompt: + inline: |- + A PVC named "stuck-pvc" in the lvms-eval-app namespace has been stuck in Pending state for over an hour. + The application team is blocked waiting for storage. Investigate why this PVC cannot bind and tell me + what needs to be fixed. diff --git a/internal/tools/update-readme/main.go b/internal/tools/update-readme/main.go index a10780111..7c25f6af2 100644 --- a/internal/tools/update-readme/main.go +++ b/internal/tools/update-readme/main.go @@ -22,6 +22,7 @@ import ( _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kcp" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt" + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/lvms" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/netobserv" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/tekton" "k8s.io/apimachinery/pkg/runtime/schema" diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index 0c5ba3c6c..22266aec9 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -7,6 +7,7 @@ import ( _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kcp" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt" + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/lvms" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/netobserv" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/tekton" ) diff --git a/pkg/mcp/testdata/toolsets-lvms-tools.json b/pkg/mcp/testdata/toolsets-lvms-tools.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/pkg/mcp/testdata/toolsets-lvms-tools.json @@ -0,0 +1 @@ +[] diff --git a/pkg/mcp/toolsets_test.go b/pkg/mcp/toolsets_test.go index 9a19cff0b..5845918c1 100644 --- a/pkg/mcp/toolsets_test.go +++ b/pkg/mcp/toolsets_test.go @@ -20,6 +20,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kcp" "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali" "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/lvms" mgToolset "github.com/containers/kubernetes-mcp-server/pkg/toolsets/mustgather" "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift" "github.com/containers/kubernetes-mcp-server/pkg/toolsets/tekton" @@ -201,6 +202,7 @@ func (s *ToolsetsSuite) TestGranularToolsetsTools() { &helm.Toolset{}, &kiali.Toolset{}, &kubevirt.Toolset{}, + &lvms.Toolset{}, &tekton.Toolset{}, &clusterDiagnosticsToolset.Toolset{}, } diff --git a/pkg/toolsets/lvms/lvms_capacity.go b/pkg/toolsets/lvms/lvms_capacity.go new file mode 100644 index 000000000..5677bd31a --- /dev/null +++ b/pkg/toolsets/lvms/lvms_capacity.go @@ -0,0 +1,350 @@ +package lvms + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +var ( + csiStorageCapacityGVR = schema.GroupVersionResource{ + Group: "storage.k8s.io", + Version: "v1", + Resource: "csistoragecapacities", + } + pvGVR = schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "persistentvolumes", + } +) + +// initLVMSCapacity initializes the LVMS capacity monitoring prompt +func initLVMSCapacity() []api.ServerPrompt { + return []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "lvms-capacity", + Title: "LVMS Capacity Check", + Description: "Check LVMS storage capacity across all nodes including available space per node, PVC usage, and capacity warnings", + Arguments: []api.PromptArgument{ + { + Name: "namespace", + Description: "The LVMS namespace (default: openshift-lvm-storage)", + Required: false, + }, + }, + }, + Handler: lvmsCapacityHandler, + }, + } +} + +// lvmsCapacityHandler implements the LVMS capacity monitoring prompt +func lvmsCapacityHandler(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + namespace := args["namespace"] + if namespace == "" { + namespace = DefaultLVMSNamespace + } + + ctx := params.Context + if ctx == nil { + ctx = context.Background() + } + + dynamicClient := params.DynamicClient() + + // Fetch all capacity data + capacitySummary := fetchCSICapacity(ctx, dynamicClient, namespace) + pvcSummary := fetchPVCCapacitySummary(ctx, dynamicClient) + pvSummary := fetchPVCapacitySummary(ctx, dynamicClient) + + guideText := fmt.Sprintf(`# LVMS Capacity Report + +## Overview + +This report shows LVMS storage capacity across all nodes to help with capacity planning +and identify nodes that may need additional storage. + +--- + +## Available Capacity by Node + +CSIStorageCapacity reports how much space TopoLVM can provision on each node: + +%s + +--- + +## PVC Usage Summary + +PVCs using TopoLVM storage classes: + +%s + +--- + +## PV Capacity Summary + +Provisioned PersistentVolumes by TopoLVM: + +%s + +--- + +## Capacity Analysis + +Based on the data above, assess: + +1. **Available Headroom**: How much free space remains per node? +2. **Usage Distribution**: Is storage evenly distributed or concentrated on few nodes? +3. **Growth Trend**: Are PVCs consuming space faster than expected? +4. **At-Risk Nodes**: Any nodes with less than 20%% free capacity? + +--- + +## Recommendations + +- **If a node has < 20%% free**: Consider adding disks to LVMCluster deviceSelector +- **If PVCs are pending**: Check if any node has sufficient capacity +- **For capacity planning**: Total available = sum of all node capacities +- **For HA workloads**: Ensure multiple nodes have sufficient capacity + +--- + +## Report Summary + +Provide: +- **Overall Status**: OK / Low Capacity / Critical +- **Total Available**: Sum across all nodes +- **Total Used**: Sum of all PV capacities +- **Utilization**: Used / (Used + Available) %% +- **Action Items**: Any immediate concerns +`, capacitySummary, pvcSummary, pvSummary) + + return api.NewPromptCallResult( + "LVMS capacity report generated", + []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: guideText, + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: "I'll analyze the LVMS capacity data to assess storage health and identify any capacity concerns.", + }, + }, + }, + nil, + ), nil +} + +// fetchCSICapacity fetches CSIStorageCapacity resources for TopoLVM +func fetchCSICapacity(ctx context.Context, dynamicClient dynamic.Interface, namespace string) string { + var result strings.Builder + + capacities, err := dynamicClient.Resource(csiStorageCapacityGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "csi.storage.k8s.io/drivername=topolvm.io", + }) + if err != nil { + return fmt.Sprintf("*Error listing CSIStorageCapacity: %v*\n", err) + } + + if len(capacities.Items) == 0 { + return "*No CSIStorageCapacity found for TopoLVM — LVMS may not be configured*\n" + } + + result.WriteString("| Node | Storage Class | Available Capacity |\n") + result.WriteString("|------|---------------|--------------------|\n") + + var totalCapacity int64 + for _, cap := range capacities.Items { + storageClass, _, _ := unstructured.NestedString(cap.Object, "storageClassName") + capacity, _, _ := unstructured.NestedString(cap.Object, "capacity") + + // Get node from topology + node := "unknown" + if topology, found, _ := unstructured.NestedMap(cap.Object, "nodeTopology", "matchLabels"); found { + if n, ok := topology["topology.topolvm.io/node"].(string); ok { + node = n + } + } + + // Parse capacity for total + totalCapacity += parseCapacity(capacity) + + fmt.Fprintf(&result, "| %s | %s | %s |\n", node, storageClass, capacity) + } + + fmt.Fprintf(&result, "\n**Total Available: %s**\n", formatCapacity(totalCapacity)) + + return result.String() +} + +// fetchPVCCapacitySummary fetches PVC summary for TopoLVM storage classes +func fetchPVCCapacitySummary(ctx context.Context, dynamicClient dynamic.Interface) string { + var result strings.Builder + + // Get TopoLVM storage classes first + scList, err := dynamicClient.Resource(storageClassGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Sprintf("*Error listing StorageClasses: %v*\n", err) + } + + topoLVMSCs := make(map[string]bool) + for _, sc := range scList.Items { + provisioner, _, _ := unstructured.NestedString(sc.Object, "provisioner") + if strings.Contains(provisioner, "topolvm") { + topoLVMSCs[sc.GetName()] = true + } + } + + if len(topoLVMSCs) == 0 { + return "*No TopoLVM storage classes found*\n" + } + + // Get PVCs + pvcList, err := dynamicClient.Resource(pvcGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Sprintf("*Error listing PVCs: %v*\n", err) + } + + var boundCount, pendingCount int + var totalRequested int64 + pendingPVCs := []string{} + + for _, pvc := range pvcList.Items { + scName, _, _ := unstructured.NestedString(pvc.Object, "spec", "storageClassName") + if !topoLVMSCs[scName] { + continue + } + + phase, _, _ := unstructured.NestedString(pvc.Object, "status", "phase") + requestedStorage, _, _ := unstructured.NestedString(pvc.Object, "spec", "resources", "requests", "storage") + + switch phase { + case "Bound": + boundCount++ + totalRequested += parseCapacity(requestedStorage) + case "Pending": + pendingCount++ + pendingPVCs = append(pendingPVCs, fmt.Sprintf("%s/%s (%s)", + pvc.GetNamespace(), pvc.GetName(), requestedStorage)) + } + } + + fmt.Fprintf(&result, "- **Bound PVCs**: %d (Total requested: %s)\n", boundCount, formatCapacity(totalRequested)) + fmt.Fprintf(&result, "- **Pending PVCs**: %d\n", pendingCount) + + if len(pendingPVCs) > 0 { + result.WriteString("\n**Pending PVCs (need attention):**\n") + for _, pvc := range pendingPVCs { + fmt.Fprintf(&result, "- %s\n", pvc) + } + } + + return result.String() +} + +// fetchPVCapacitySummary fetches PV summary for TopoLVM +func fetchPVCapacitySummary(ctx context.Context, dynamicClient dynamic.Interface) string { + var result strings.Builder + + pvList, err := dynamicClient.Resource(pvGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Sprintf("*Error listing PVs: %v*\n", err) + } + + var topoLVMCount int + var totalCapacity int64 + nodeCapacity := make(map[string]int64) + + for _, pv := range pvList.Items { + csiDriver, _, _ := unstructured.NestedString(pv.Object, "spec", "csi", "driver") + if csiDriver != "topolvm.io" { + continue + } + + topoLVMCount++ + capacity, _, _ := unstructured.NestedString(pv.Object, "spec", "capacity", "storage") + capBytes := parseCapacity(capacity) + totalCapacity += capBytes + + // Get node affinity + terms, found, _ := unstructured.NestedSlice(pv.Object, "spec", "nodeAffinity", "required", "nodeSelectorTerms") + if found && len(terms) > 0 { + if term, ok := terms[0].(map[string]any); ok { + if exprs, ok := term["matchExpressions"].([]any); ok { + for _, expr := range exprs { + if e, ok := expr.(map[string]any); ok { + if e["key"] == "topology.topolvm.io/node" { + if values, ok := e["values"].([]any); ok && len(values) > 0 { + if node, ok := values[0].(string); ok { + nodeCapacity[node] += capBytes + } + } + } + } + } + } + } + } + } + + fmt.Fprintf(&result, "- **Total PVs**: %d\n", topoLVMCount) + fmt.Fprintf(&result, "- **Total Provisioned**: %s\n", formatCapacity(totalCapacity)) + + if len(nodeCapacity) > 0 { + result.WriteString("\n**Provisioned by Node:**\n\n") + result.WriteString("| Node | Provisioned |\n") + result.WriteString("|------|-------------|\n") + for node, cap := range nodeCapacity { + fmt.Fprintf(&result, "| %s | %s |\n", node, formatCapacity(cap)) + } + } + + return result.String() +} + +// parseCapacity parses a Kubernetes quantity string to bytes +func parseCapacity(s string) int64 { + if s == "" { + return 0 + } + q, err := resource.ParseQuantity(s) + if err != nil { + return 0 + } + return q.Value() +} + +// formatCapacity formats bytes to human readable string +func formatCapacity(bytes int64) string { + if bytes == 0 { + return "0" + } + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} diff --git a/pkg/toolsets/lvms/lvms_troubleshoot.go b/pkg/toolsets/lvms/lvms_troubleshoot.go new file mode 100644 index 000000000..5a2b4f1ab --- /dev/null +++ b/pkg/toolsets/lvms/lvms_troubleshoot.go @@ -0,0 +1,760 @@ +package lvms + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/output" +) + +const ( + // DefaultLVMSNamespace is the default namespace for LVMS components + DefaultLVMSNamespace = "openshift-lvm-storage" +) + +// GVRs for LVMS resources +var ( + lvmClusterGVR = schema.GroupVersionResource{ + Group: "lvm.topolvm.io", + Version: "v1alpha1", + Resource: "lvmclusters", + } + lvmVolumeGroupNodeStatusGVR = schema.GroupVersionResource{ + Group: "lvm.topolvm.io", + Version: "v1alpha1", + Resource: "lvmvolumegroupnodestatuses", + } + podGVR = schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "pods", + } + csvGVR = schema.GroupVersionResource{ + Group: "operators.coreos.com", + Version: "v1alpha1", + Resource: "clusterserviceversions", + } + storageClassGVR = schema.GroupVersionResource{ + Group: "storage.k8s.io", + Version: "v1", + Resource: "storageclasses", + } + pvcGVR = schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "persistentvolumeclaims", + } +) + +// initLVMSTroubleshoot initializes the LVMS troubleshooting prompt +func initLVMSTroubleshoot() []api.ServerPrompt { + return []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "lvms-troubleshoot", + Title: "LVMS Troubleshoot", + Description: "Diagnose LVMS storage issues by gathering LVMCluster status, volume group health, and node-level LVM data with interpretation of domain-specific fields", + Arguments: []api.PromptArgument{ + { + Name: "namespace", + Description: "The LVMS namespace (default: openshift-lvm-storage)", + Required: false, + }, + { + Name: "node", + Description: "Specific node to inspect (default: all nodes with LVMS)", + Required: false, + }, + }, + }, + Handler: lvmsTroubleshootHandler, + }, + } +} + +// lvmsTroubleshootHandler implements the LVMS troubleshooting prompt +func lvmsTroubleshootHandler(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + namespace := args["namespace"] + if namespace == "" { + namespace = DefaultLVMSNamespace + } + targetNode := args["node"] + + ctx := params.Context + if ctx == nil { + ctx = context.Background() + } + + dynamicClient := params.DynamicClient() + + // Fetch all relevant LVMS resources + operatorHealthYaml := fetchOperatorHealth(ctx, dynamicClient, namespace) + lvmClusterYaml := fetchLVMClusterStatus(ctx, dynamicClient, namespace) + vgNodeStatusYaml := fetchVolumeGroupNodeStatus(ctx, dynamicClient, namespace, targetNode) + storageClassYaml := fetchTopoLVMStorageClasses(ctx, dynamicClient) + pvcStatusYaml := fetchTopoLVMPVCs(ctx, dynamicClient) + vgManagerPodsYaml, podNames := fetchVGManagerPods(ctx, dynamicClient, namespace) + vgManagerLogsText := fetchVGManagerLogs(ctx, params.KubernetesClient, namespace, podNames) + eventsYaml := fetchLVMSEvents(ctx, params.KubernetesClient, namespace) + + guideText := fmt.Sprintf(`# LVMS Troubleshooting Guide + +## Namespace: %s + +Use this guide to diagnose LVMS storage issues. The relevant resource data and LVM-specific field interpretations are provided below. + +--- + +## LVM Attribute Field Reference + +Understanding these fields is critical for LVMS troubleshooting: + +### Volume Group Attributes (vg_attr) +The vg_attr field is a 6-character string where each position has meaning: + +| Position | Character | Meaning | +|----------|-----------|---------| +| 1 | w/r | Writeable / Read-only | +| 2 | z/- | Resizeable / Fixed | +| 3 | x/- | Exported / Normal | +| 4 | p/- | Partial (missing PVs) / Complete | +| 5 | c/l/n/s | Allocation: contiguous/cling/normal/anywhere | +| 6 | c/- | Clustered / Local | + +**Healthy VG:** "wz--n-" (writeable, resizeable, not partial, normal allocation) +**Degraded VG:** "wz-pn-" (the 'p' in position 4 means missing PVs!) + +### Logical Volume Attributes (lv_attr) +The lv_attr field is a 10-character string. Key positions for LVMS: + +| Position | Key Values | Meaning | +|----------|------------|---------| +| 1 | V/v/t | Virtual (thin), thin pool, or other type | +| 5 | a/- | Active / Inactive | +| 9 | p/- | Partial activation (RAID degraded!) / Normal | + +**Healthy LV:** Position 9 is '-' +**Degraded RAID LV:** Position 9 is 'p' (partial) + +### Key Diagnostic Fields +- **vg_missing_pv_count > 0**: Volume group has missing physical volumes (disk failure) +- **vg_free**: Available space in volume group +- **lv_health_status**: "partial" indicates RAID degradation +- **raid_sync_percent**: < 100 means RAID is rebuilding + +--- + +## Step 1: Operator Health + +Check LVMS operator status: +- CSV should be "Succeeded" +- All operator pods should be "Running" + +%s + +--- + +## Step 2: LVMCluster Status + +The LVMCluster CR is the primary configuration. Check: +- Does an LVMCluster exist? +- What is the state? ("Ready" = healthy) +- Are all deviceClasses configured correctly? +- **Device paths**: Use /dev/disk/by-id/ not /dev/sdX (sdX paths change on reboot!) + +%s + +--- + +## Step 3: Volume Group Node Status + +LVMVolumeGroupNodeStatus shows per-node volume group health: +- Check status for each node +- Look for "Degraded" or "Failed" states +- Review raidStatus if RAID is configured + +%s + +--- + +## Step 4: Storage Classes + +TopoLVM storage classes provisioned by LVMS: + +%s + +--- + +## Step 5: PVC Status + +PVCs using TopoLVM provisioner: +- Pending PVCs indicate capacity or configuration issues +- Check events on Pending PVCs for root cause + +%s + +--- + +## Step 6: vg-manager Pod Health + +The vg-manager pods manage volume groups on each node: +- Pods should be "Running" with all containers ready +- CrashLoopBackOff indicates disk discovery issues +- High restart counts suggest persistent problems + +%s + +--- + +## Step 7: vg-manager Pod Logs + +Review logs for errors: +- Look for disk discovery failures +- Check for LVM command errors +- Review any "error" or "failed" messages + +%s + +--- + +## Step 8: Events + +Review events in the LVMS namespace: +- Warning events indicate problems +- Look for scheduling, disk, or permission issues + +%s + +--- + +## Troubleshooting Analysis + +Based on the data above, analyze: + +1. **LVMCluster State**: Is it "Ready"? If not, what deviceClass is failing? +2. **Node Status**: Are all nodes healthy? Look for "Degraded" or "Failed" states in LVMVolumeGroupNodeStatus. +3. **vg-manager Pods**: Are they running? Check for CrashLoopBackOff or high restart counts. +4. **Events**: Are there Warning events indicating problems? + +--- + +## Common Issues and Recovery Procedures + +### 1. VG has missing PVs (vg_attr contains 'p') +Disk failure detected. Recovery options: +- **Option A - Reduce VG**: Remove failed disk from VG + `+"`"+`vgchange --activate n `+"`"+` + `+"`"+`vgreduce --removemissing --force `+"`"+` + Then patch LVMCluster to remove failed device path. + +- **Option B - Replace disk**: If replacement disk available + `+"`"+`pvcreate --restorefile /etc/lvm/backup/ --uuid /dev/`+"`"+` + `+"`"+`vgextend --restoremissing /dev/`+"`"+` + +### 2. Thin pool nearly full (data_percent > 80%%) +- Extend the thin pool or add disks +- Delete unused PVCs to free space +- Check for stuck snapshots consuming space + +### 3. vg-manager CrashLoopBackOff +- Check if deviceSelector.paths point to valid disks +- Run `+"`"+`lsblk --paths --json`+"`"+` on the node to verify disk availability +- Ensure disks are not in use by other systems + +### 4. RAID degraded (lv_health_status: partial) +- Check raid_sync_percent - if syncing, wait for completion +- If stuck at 0%%, the RAID may need repair: + `+"`"+`lvconvert --repair /`+"`"+` + +### 5. LVMCluster deletion stuck +Perform forced cleanup (in order): +1. Delete all PVCs using LVMS +2. Remove finalizers from LogicalVolumes, then delete them +3. Remove finalizers from LVMVolumeGroups, then delete them +4. Remove finalizers from LVMVolumeGroupNodeStatus, then delete them +5. Delete LVMCluster + +### 6. PVC stuck in Pending +- Check if StorageClass exists +- Verify LVMCluster is Ready +- Check node has available capacity +- If using WaitForFirstConsumer, PVC stays Pending until a Pod requests it (this is normal) + +--- + +## Report Findings + +After analysis, report: +- **Status:** Healthy / Degraded / Failed +- **Root Cause:** Description of the issue found (or "None found") +- **Affected Nodes:** List of nodes with problems +- **Recommended Action:** Steps to resolve the issue +`, namespace, operatorHealthYaml, lvmClusterYaml, vgNodeStatusYaml, storageClassYaml, pvcStatusYaml, vgManagerPodsYaml, vgManagerLogsText, eventsYaml) + + return api.NewPromptCallResult( + "LVMS troubleshooting guide generated", + []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: guideText, + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: "I'll analyze the collected LVMS data to diagnose storage issues, paying special attention to vg_attr flags and node-level health indicators.", + }, + }, + }, + nil, + ), nil +} + +// fetchLVMClusterStatus fetches LVMCluster resources and returns their status +func fetchLVMClusterStatus(ctx context.Context, dynamicClient dynamic.Interface, namespace string) string { + clusters, err := dynamicClient.Resource(lvmClusterGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Sprintf("### LVMCluster\n\n*Error listing LVMClusters: %v*", err) + } + + if len(clusters.Items) == 0 { + return "### LVMCluster\n\n*No LVMCluster found — LVMS may not be configured*" + } + + var result strings.Builder + result.WriteString("### LVMCluster\n\n") + + for _, cluster := range clusters.Items { + fmt.Fprintf(&result, "#### %s\n\n", cluster.GetName()) + + // Get overall state + state, _, _ := unstructured.NestedString(cluster.Object, "status", "state") + fmt.Fprintf(&result, "**State:** %s\n\n", valueOrNA(state)) + + // Get deviceClassStatuses + deviceStatuses, found, _ := unstructured.NestedSlice(cluster.Object, "status", "deviceClassStatuses") + if found && len(deviceStatuses) > 0 { + result.WriteString("**Device Class Statuses:**\n\n") + result.WriteString("| Name | Node | Status |\n") + result.WriteString("|------|------|--------|\n") + + for _, ds := range deviceStatuses { + dsMap, ok := ds.(map[string]any) + if !ok { + continue + } + name, _ := dsMap["name"].(string) + nodeStatuses, _ := dsMap["nodeStatus"].([]any) + for _, ns := range nodeStatuses { + nsMap, ok := ns.(map[string]any) + if !ok { + continue + } + nodeName, _ := nsMap["node"].(string) + status, _ := nsMap["status"].(string) + fmt.Fprintf(&result, "| %s | %s | %s |\n", name, nodeName, status) + } + } + result.WriteString("\n") + } + + // Get spec for device configuration + spec, found, _ := unstructured.NestedMap(cluster.Object, "spec", "storage") + if found { + specYaml, err := output.MarshalYaml(spec) + if err == nil { + fmt.Fprintf(&result, "**Storage Spec:**\n```yaml\n%s```\n\n", specYaml) + } + } + } + + return result.String() +} + +// fetchVolumeGroupNodeStatus fetches LVMVolumeGroupNodeStatus resources +func fetchVolumeGroupNodeStatus(ctx context.Context, dynamicClient dynamic.Interface, namespace, targetNode string) string { + statuses, err := dynamicClient.Resource(lvmVolumeGroupNodeStatusGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Sprintf("### LVMVolumeGroupNodeStatus\n\n*Error listing node statuses: %v*", err) + } + + if len(statuses.Items) == 0 { + return "### LVMVolumeGroupNodeStatus\n\n*No LVMVolumeGroupNodeStatus found*" + } + + var result strings.Builder + result.WriteString("### LVMVolumeGroupNodeStatus\n\n") + + for _, status := range statuses.Items { + nodeName := status.GetName() + + // Filter by target node if specified + if targetNode != "" && nodeName != targetNode { + continue + } + + fmt.Fprintf(&result, "#### Node: %s\n\n", nodeName) + + // Get spec which contains the actual status data + spec, found, _ := unstructured.NestedMap(status.Object, "spec") + if !found { + result.WriteString("*No spec found*\n\n") + continue + } + + specYaml, err := output.MarshalYaml(spec) + if err == nil { + fmt.Fprintf(&result, "```yaml\n%s```\n\n", specYaml) + } + } + + return result.String() +} + +// fetchVGManagerPods fetches vg-manager pods +func fetchVGManagerPods(ctx context.Context, dynamicClient dynamic.Interface, namespace string) (string, []string) { + pods, err := dynamicClient.Resource(podGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/name=vg-manager", + }) + if err != nil { + return fmt.Sprintf("### vg-manager Pods\n\n*Error listing pods: %v*", err), nil + } + + if len(pods.Items) == 0 { + return "### vg-manager Pods\n\n*No vg-manager pods found — LVMS may not be installed*", nil + } + + var result strings.Builder + var podNames []string + result.WriteString("### vg-manager Pods\n\n") + result.WriteString("| Name | Node | Phase | Ready | Restarts |\n") + result.WriteString("|------|------|-------|-------|----------|\n") + + for _, pod := range pods.Items { + podNames = append(podNames, pod.GetName()) + phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase") + nodeName, _, _ := unstructured.NestedString(pod.Object, "spec", "nodeName") + ready, restarts := getPodReadyAndRestarts(&pod) + + fmt.Fprintf(&result, "| %s | %s | %s | %s | %d |\n", + pod.GetName(), valueOrNA(nodeName), valueOrNA(phase), ready, restarts) + } + result.WriteString("\n") + + return result.String(), podNames +} + +// fetchVGManagerLogs fetches logs from vg-manager pods +func fetchVGManagerLogs(ctx context.Context, client api.KubernetesClient, namespace string, podNames []string) string { + if len(podNames) == 0 { + return "### vg-manager Logs\n\n*No vg-manager pods found*" + } + + core := kubernetes.NewCore(client) + var result strings.Builder + result.WriteString("### vg-manager Logs\n\n") + + for _, podName := range podNames { + fmt.Fprintf(&result, "#### Pod: %s\n\n", podName) + // Use empty container name to get logs from the default container + logs, err := core.PodsLog(ctx, namespace, podName, "", false, 100) + if err != nil { + fmt.Fprintf(&result, "*Error fetching logs: %v*\n\n", err) + continue + } + + errorLines := filterLogLines(logs, []string{"error", "failed", "unable", "cannot", "no available"}) + if errorLines != "" { + fmt.Fprintf(&result, "**Error lines (last 100 log lines):**\n```\n%s\n```\n\n", errorLines) + } else { + result.WriteString("*No error lines found in recent logs*\n\n") + } + } + + return result.String() +} + +// fetchLVMSEvents fetches events in the LVMS namespace +func fetchLVMSEvents(ctx context.Context, client api.KubernetesClient, namespace string) string { + core := kubernetes.NewCore(client) + eventMap, err := core.EventsList(ctx, namespace, api.ListOptions{}) + if err != nil { + return fmt.Sprintf("### Events\n\n*Error listing events: %v*", err) + } + + if len(eventMap) == 0 { + return "### Events\n\n*No events found in namespace*" + } + + // Filter for warning events + var warningEvents []map[string]any + for _, event := range eventMap { + eventType, _ := event["Type"].(string) + if eventType == "Warning" { + warningEvents = append(warningEvents, event) + } + } + + if len(warningEvents) == 0 { + return "### Events\n\n*No Warning events found — this is good*" + } + + // Limit to most recent 10 + if len(warningEvents) > 10 { + warningEvents = warningEvents[len(warningEvents)-10:] + } + + yamlStr, err := output.MarshalYaml(warningEvents) + if err != nil { + return fmt.Sprintf("### Events\n\n*Error marshaling events: %v*", err) + } + + return fmt.Sprintf("### Warning Events (last 10)\n\n```yaml\n%s```", yamlStr) +} + +// Helper functions + +func valueOrNA(s string) string { + if s == "" { + return "N/A" + } + return s +} + +func getPodReadyAndRestarts(pod *unstructured.Unstructured) (string, int64) { + containerStatuses, found, _ := unstructured.NestedSlice(pod.Object, "status", "containerStatuses") + if !found { + return "N/A", 0 + } + + totalContainers := len(containerStatuses) + readyCount := 0 + var totalRestarts int64 + + for _, cs := range containerStatuses { + csMap, ok := cs.(map[string]any) + if !ok { + continue + } + if ready, ok := csMap["ready"].(bool); ok && ready { + readyCount++ + } + switch restarts := csMap["restartCount"].(type) { + case int64: + totalRestarts += restarts + case float64: + totalRestarts += int64(restarts) + case json.Number: + if r, err := restarts.Int64(); err == nil { + totalRestarts += r + } + } + } + + return fmt.Sprintf("%d/%d", readyCount, totalContainers), totalRestarts +} + +func filterLogLines(logs string, keywords []string) string { + var matched []string + for _, line := range strings.Split(logs, "\n") { + lower := strings.ToLower(line) + for _, kw := range keywords { + if strings.Contains(lower, strings.ToLower(kw)) { + matched = append(matched, line) + break + } + } + } + return strings.Join(matched, "\n") +} + +// fetchOperatorHealth fetches LVMS operator CSV and pod status +func fetchOperatorHealth(ctx context.Context, dynamicClient dynamic.Interface, namespace string) string { + var result strings.Builder + result.WriteString("### Operator Health\n\n") + + // Get CSV status + csvList, err := dynamicClient.Resource(csvGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + fmt.Fprintf(&result, "*Error listing CSVs: %v*\n\n", err) + } else { + var lvmsCSV *unstructured.Unstructured + for i := range csvList.Items { + csv := &csvList.Items[i] + name := csv.GetName() + if strings.HasPrefix(name, "lvms-operator") || strings.HasPrefix(name, "lvm-operator") { + lvmsCSV = csv + break + } + } + if lvmsCSV == nil { + result.WriteString("*No LVMS operator CSV found*\n\n") + } else { + phase, _, _ := unstructured.NestedString(lvmsCSV.Object, "status", "phase") + reason, _, _ := unstructured.NestedString(lvmsCSV.Object, "status", "reason") + fmt.Fprintf(&result, "**CSV:** %s\n- **Phase:** %s\n- **Reason:** %s\n\n", + lvmsCSV.GetName(), valueOrNA(phase), valueOrNA(reason)) + } + } + + // Get operator pods + labelSelector := "app.kubernetes.io/name=lvms-operator" + podList, err := dynamicClient.Resource(podGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + fmt.Fprintf(&result, "*Error listing operator pods: %v*\n\n", err) + } else if len(podList.Items) == 0 { + result.WriteString("*No LVMS operator pods found*\n\n") + } else { + result.WriteString("**Operator Pods:**\n\n") + for _, pod := range podList.Items { + phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase") + ready, restarts := getPodReadyAndRestarts(&pod) + fmt.Fprintf(&result, "- %s: %s (Ready: %s, Restarts: %d)\n", + pod.GetName(), phase, ready, restarts) + } + result.WriteString("\n") + } + + return result.String() +} + +// fetchTopoLVMStorageClasses fetches storage classes provisioned by TopoLVM +func fetchTopoLVMStorageClasses(ctx context.Context, dynamicClient dynamic.Interface) string { + var result strings.Builder + result.WriteString("### TopoLVM Storage Classes\n\n") + + scList, err := dynamicClient.Resource(storageClassGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + fmt.Fprintf(&result, "*Error listing StorageClasses: %v*\n\n", err) + return result.String() + } + + var topoLVMSCs []map[string]any + for _, sc := range scList.Items { + provisioner, _, _ := unstructured.NestedString(sc.Object, "provisioner") + if strings.Contains(provisioner, "topolvm") { + scData := map[string]any{ + "name": sc.GetName(), + "provisioner": provisioner, + "reclaimPolicy": sc.Object["reclaimPolicy"], + "volumeBindingMode": sc.Object["volumeBindingMode"], + "allowVolumeExpansion": sc.Object["allowVolumeExpansion"], + } + if params, found, _ := unstructured.NestedStringMap(sc.Object, "parameters"); found { + scData["parameters"] = params + } + topoLVMSCs = append(topoLVMSCs, scData) + } + } + + if len(topoLVMSCs) == 0 { + result.WriteString("*No TopoLVM storage classes found*\n\n") + return result.String() + } + + yamlStr, err := output.MarshalYaml(topoLVMSCs) + if err != nil { + fmt.Fprintf(&result, "*Error marshaling storage classes: %v*\n\n", err) + return result.String() + } + + fmt.Fprintf(&result, "```yaml\n%s```\n\n", yamlStr) + return result.String() +} + +// fetchTopoLVMPVCs fetches PVCs using TopoLVM storage classes +func fetchTopoLVMPVCs(ctx context.Context, dynamicClient dynamic.Interface) string { + var result strings.Builder + result.WriteString("### PVCs Using TopoLVM\n\n") + + // First get TopoLVM storage class names + scList, err := dynamicClient.Resource(storageClassGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + fmt.Fprintf(&result, "*Error listing StorageClasses: %v*\n\n", err) + return result.String() + } + + topoLVMSCNames := make(map[string]bool) + for _, sc := range scList.Items { + provisioner, _, _ := unstructured.NestedString(sc.Object, "provisioner") + if strings.Contains(provisioner, "topolvm") { + topoLVMSCNames[sc.GetName()] = true + } + } + + if len(topoLVMSCNames) == 0 { + result.WriteString("*No TopoLVM storage classes found*\n\n") + return result.String() + } + + // Get all PVCs + pvcList, err := dynamicClient.Resource(pvcGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + fmt.Fprintf(&result, "*Error listing PVCs: %v*\n\n", err) + return result.String() + } + + var pendingPVCs, boundPVCs []map[string]any + for _, pvc := range pvcList.Items { + scName, _, _ := unstructured.NestedString(pvc.Object, "spec", "storageClassName") + if !topoLVMSCNames[scName] { + continue + } + + phase, _, _ := unstructured.NestedString(pvc.Object, "status", "phase") + capacity, _, _ := unstructured.NestedString(pvc.Object, "status", "capacity", "storage") + + pvcData := map[string]any{ + "namespace": pvc.GetNamespace(), + "name": pvc.GetName(), + "storageClass": scName, + "phase": phase, + "capacity": capacity, + } + + if phase == "Pending" { + pendingPVCs = append(pendingPVCs, pvcData) + } else { + boundPVCs = append(boundPVCs, pvcData) + } + } + + if len(pendingPVCs) > 0 { + result.WriteString("**Pending PVCs (needs attention):**\n\n") + yamlStr, _ := output.MarshalYaml(pendingPVCs) + fmt.Fprintf(&result, "```yaml\n%s```\n\n", yamlStr) + } + + if len(boundPVCs) > 0 { + // Limit bound PVCs to 20 most recent + if len(boundPVCs) > 20 { + boundPVCs = boundPVCs[len(boundPVCs)-20:] + result.WriteString("**Bound PVCs (showing last 20):**\n\n") + } else { + result.WriteString("**Bound PVCs:**\n\n") + } + yamlStr, _ := output.MarshalYaml(boundPVCs) + fmt.Fprintf(&result, "```yaml\n%s```\n\n", yamlStr) + } + + if len(pendingPVCs) == 0 && len(boundPVCs) == 0 { + result.WriteString("*No PVCs found using TopoLVM storage classes*\n\n") + } + + return result.String() +} diff --git a/pkg/toolsets/lvms/toolset.go b/pkg/toolsets/lvms/toolset.go new file mode 100644 index 000000000..bebda8d7b --- /dev/null +++ b/pkg/toolsets/lvms/toolset.go @@ -0,0 +1,48 @@ +package lvms + +import ( + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +// Toolset provides LVMS (Logical Volume Manager Storage) prompts for troubleshooting +// storage issues on OpenShift clusters. LVMS resources are managed via the core +// toolset's generic resource tools. This toolset provides a troubleshooting prompt +// that gathers diagnostic data and explains LVMS-specific fields like vg_attr flags. +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +// GetName returns the name of the toolset. +func (t *Toolset) GetName() string { + return "lvms" +} + +// GetDescription returns a human-readable description of the toolset. +func (t *Toolset) GetDescription() string { + return "LVMS (Logical Volume Manager Storage) troubleshooting prompts for diagnosing storage issues" +} + +// GetTools returns nil — LVMS resources are managed via the core toolset's generic resource tools. +func (t *Toolset) GetTools(_ api.FilteringProvider) []api.ServerTool { + return nil +} + +// GetPrompts returns the prompts provided by this toolset. +func (t *Toolset) GetPrompts() []api.ServerPrompt { + return append(initLVMSTroubleshoot(), initLVMSCapacity()...) +} + +// GetResources returns the resources provided by this toolset. +func (t *Toolset) GetResources() []api.ServerResource { + return nil +} + +// GetResourceTemplates returns the resource templates provided by this toolset. +func (t *Toolset) GetResourceTemplates() []api.ServerResourceTemplate { + return nil +} + +func init() { + toolsets.Register(&Toolset{}) +} diff --git a/pkg/toolsets/lvms/toolset_test.go b/pkg/toolsets/lvms/toolset_test.go new file mode 100644 index 000000000..741ffac52 --- /dev/null +++ b/pkg/toolsets/lvms/toolset_test.go @@ -0,0 +1,81 @@ +package lvms + +import ( + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToolsetImplementsInterface(t *testing.T) { + var _ api.Toolset = (*Toolset)(nil) +} + +func TestToolsetGetName(t *testing.T) { + toolset := &Toolset{} + assert.Equal(t, "lvms", toolset.GetName()) +} + +func TestToolsetGetDescription(t *testing.T) { + toolset := &Toolset{} + desc := toolset.GetDescription() + assert.Contains(t, desc, "LVMS") + assert.Contains(t, desc, "troubleshooting") +} + +func TestToolsetGetTools(t *testing.T) { + toolset := &Toolset{} + tools := toolset.GetTools(nil) + + // LVMS is a prompts-only toolset - no tools needed. + // All LVMS operations are covered by: + // - Core tools (resources_list, resources_get) for LVMS CRD queries + // - nodes_debug_exec (cluster-diagnostics) for raw LVM commands on nodes + assert.Nil(t, tools, "LVMS toolset should have no tools - it's a prompts-only toolset") +} + +func TestToolsetGetPrompts(t *testing.T) { + toolset := &Toolset{} + prompts := toolset.GetPrompts() + + require.NotEmpty(t, prompts, "toolset should return prompts") + require.Len(t, prompts, 2, "toolset should return 2 prompts") + + // Verify lvms-troubleshoot prompt + var foundTroubleshoot, foundCapacity bool + for _, prompt := range prompts { + switch prompt.Prompt.Name { + case "lvms-troubleshoot": + foundTroubleshoot = true + assert.Equal(t, "LVMS Troubleshoot", prompt.Prompt.Title) + assert.NotEmpty(t, prompt.Prompt.Description) + assert.NotNil(t, prompt.Handler) + require.Len(t, prompt.Prompt.Arguments, 2) + assert.Equal(t, "namespace", prompt.Prompt.Arguments[0].Name) + assert.Equal(t, "node", prompt.Prompt.Arguments[1].Name) + + case "lvms-capacity": + foundCapacity = true + assert.Equal(t, "LVMS Capacity Check", prompt.Prompt.Title) + assert.NotEmpty(t, prompt.Prompt.Description) + assert.NotNil(t, prompt.Handler) + require.Len(t, prompt.Prompt.Arguments, 1) + assert.Equal(t, "namespace", prompt.Prompt.Arguments[0].Name) + } + } + assert.True(t, foundTroubleshoot, "lvms-troubleshoot prompt should be registered") + assert.True(t, foundCapacity, "lvms-capacity prompt should be registered") +} + +func TestToolsetGetResources(t *testing.T) { + toolset := &Toolset{} + resources := toolset.GetResources() + assert.Nil(t, resources, "LVMS toolset currently has no resources") +} + +func TestToolsetGetResourceTemplates(t *testing.T) { + toolset := &Toolset{} + templates := toolset.GetResourceTemplates() + assert.Nil(t, templates, "LVMS toolset currently has no resource templates") +}