From 313c9856516303ef097096e1f6a83a97b2ea460b Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 7 Jun 2026 12:40:37 +0530 Subject: [PATCH 1/7] feat: implement Phase 3 kdm analyze command and core analyzers --- .gitignore | 1 + docs/what-is-kdm.md | 658 +++++++++++++++++++++ src/__tests__/analysis.test.ts | 11 + src/__tests__/analyze-command.test.ts | 83 +++ src/__tests__/kubernetes-analyzers.test.ts | 205 +++++++ src/analysis/analysis.ts | 2 + src/analysis/types.ts | 2 + src/analyzers/deployment.ts | 52 ++ src/analyzers/index.ts | 39 +- src/analyzers/node.ts | 49 ++ src/analyzers/pod.ts | 69 +++ src/analyzers/pvc.ts | 50 ++ src/analyzers/service.ts | 98 +++ src/analyzers/types.ts | 2 + src/commands/analyze.ts | 60 ++ src/commands/root.ts | 3 +- src/kubernetes/client.ts | 39 +- src/kubernetes/resources.ts | 86 +++ 18 files changed, 1477 insertions(+), 32 deletions(-) create mode 100644 docs/what-is-kdm.md create mode 100644 src/__tests__/analyze-command.test.ts create mode 100644 src/__tests__/kubernetes-analyzers.test.ts create mode 100644 src/analyzers/deployment.ts create mode 100644 src/analyzers/node.ts create mode 100644 src/analyzers/pod.ts create mode 100644 src/analyzers/pvc.ts create mode 100644 src/analyzers/service.ts create mode 100644 src/commands/analyze.ts create mode 100644 src/kubernetes/resources.ts diff --git a/.gitignore b/.gitignore index cdc9585..e2846d9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .env .npmrc BLUEPRINT.md +implemented.md diff --git a/docs/what-is-kdm.md b/docs/what-is-kdm.md new file mode 100644 index 0000000..8509b82 --- /dev/null +++ b/docs/what-is-kdm.md @@ -0,0 +1,658 @@ +# What Is KDM? + +KDM, short for Kubernetes Docker Monitor, is a terminal-based monitoring CLI for developers and DevOps engineers who work with both Docker and Kubernetes. + +It gives you one command-line tool for checking local Docker containers, Kubernetes pods, Minikube status, workload health, logs, live dashboards, and alert notifications. + +> Note: The project is named **KDM** and the executable command is `kdm`. If you see “KDC” in notes or conversations, it usually refers to this KDM CLI. + +## Why KDM Exists + +Many local development and DevOps workflows involve more than one runtime: + +- Docker containers running locally. +- Kubernetes pods running in a local or remote cluster. +- Minikube profiles for local Kubernetes testing. +- Services that need quick health checks and logs. + +Tools such as `docker ps`, `kubectl get pods`, and `kubectl logs` are powerful, but they focus on separate layers. KDM combines the most common monitoring actions into one CLI so you can inspect Docker and Kubernetes workloads from a single terminal interface. + +## What KDM Can Do + +KDM currently supports: + +- Auto-detecting Docker and Kubernetes availability. +- Showing Docker containers. +- Showing Kubernetes pods. +- Showing a unified runner view of containers and pods. +- Showing Minikube profile status. +- Checking workload health. +- Watching health in refresh mode. +- Opening a live terminal dashboard. +- Fetching logs from containers or pods. +- Sending alert notifications through Discord or email SMTP. +- Storing local configuration for notification and runtime behavior. + +## Core Commands + +### Welcome And Connection Check + +Run: + +```bash +kdm +``` + +KDM checks: + +- Docker connection. +- Kubernetes connection. +- Minikube availability. +- Running container count. +- Running pod count. + +It then prints a small dashboard and command summary. + +### Show Workloads + +```bash +kdm show runners +kdm show pods +kdm show containers +kdm show minikube +``` + +`kdm show runners` combines Docker containers and Kubernetes pods in one table. + +### Health Checks + +```bash +kdm health all +kdm health pods +kdm health containers +``` + +Watch mode: + +```bash +kdm health all -w +kdm health pods -w -i 2 +``` + +The `-w` flag keeps refreshing the health output. The `-i` flag controls the refresh interval in seconds. + +### Live Dashboard + +```bash +kdm watch +``` + +This opens an Ink-based terminal UI that refreshes workload data automatically. + +### Logs + +```bash +kdm logs +``` + +KDM attempts to fetch logs for the given workload name. Docker is tried first, and Kubernetes can be used as a fallback depending on the workload. + +### Configuration + +```bash +kdm config setup +kdm config list +kdm config set +kdm config clear +``` + +Use `kdm config setup` to configure notifications interactively. + +## How KDM Works + +KDM is a Node.js and TypeScript CLI. It uses a command-based architecture where each top-level feature is registered as a Commander command. + +At a high level: + +```text +User runs kdm command + | + v +Commander parses command and flags + | + v +KDM calls Docker, Kubernetes, or Minikube modules + | + v +Data is normalized into tables, health output, logs, or dashboard state + | + v +Alerts may be triggered for failure states +``` + +## Main Internal Components + +### CLI Layer + +KDM uses Commander.js to define commands such as: + +- `show` +- `health` +- `watch` +- `logs` +- `config` + +The CLI entrypoint registers these commands and handles the default no-argument dashboard. + +### Docker Layer + +KDM uses `dockerode` to communicate with the Docker daemon. + +It can: + +- Check whether Docker is reachable. +- List containers. +- Read container status. +- Detect restart or failed-exit states. +- Trigger alerts for unhealthy Docker conditions. + +Docker must be running for Docker-based features to work. + +### Kubernetes Layer + +KDM uses `@kubernetes/client-node` to communicate with Kubernetes. + +It loads Kubernetes configuration from the default kubeconfig location, usually: + +```text +~/.kube/config +``` + +It can: + +- Check whether the Kubernetes API is reachable. +- List pods across namespaces. +- Read pod phase and restart counts. +- Detect common failure states such as `Failed`, `CrashLoopBackOff`, `ImagePullBackOff`, and `CreateContainerConfigError`. + +### Minikube Layer + +KDM can check whether Minikube is installed and whether profiles are running. + +This is useful for local Kubernetes development where Docker and Minikube are both part of the workflow. + +### UI Layer + +KDM uses: + +- `chalk` for terminal colors. +- `cli-table3` for tables. +- `Ink` and `React` for the live dashboard. + +### Config Layer + +KDM stores local configuration with the `conf` package. + +Common config values include: + +- Notification service. +- Discord webhook URL. +- SMTP host and user. +- Alert cooldown. + +Config locations: + +| OS | Location | +|---|---| +| macOS | `~/Library/Application Support/kdm-cli` | +| Linux | `~/.config/kdm-cli` | +| Windows | `%APPDATA%\kdm-cli` | + +### Alert Layer + +KDM can send alerts when it detects failure states. + +Supported notification targets: + +- Discord webhook. +- Email SMTP. + +Examples of alert-worthy states: + +- Docker container restarting. +- Docker container exited with non-zero code. +- Kubernetes pod phase is `Failed`. +- Kubernetes container is in `CrashLoopBackOff`. +- Kubernetes container is in `ImagePullBackOff`. + +SMTP passwords should be provided through: + +```bash +KDM_SMTP_PASSWORD +``` + +This avoids storing plaintext SMTP credentials in local config. + +## Requirements + +KDM requires: + +- Node.js 18 or newer. +- npm or another compatible Node package manager. +- Docker Desktop or Docker Engine for Docker features. +- Kubernetes access for Kubernetes features. +- Optional: Minikube for local Kubernetes status checks. + +Check Node: + +```bash +node --version +npm --version +``` + +Check Docker: + +```bash +docker version +docker ps +``` + +Check Kubernetes: + +```bash +kubectl config current-context +kubectl get pods --all-namespaces +``` + +## Installing KDM + +The easiest installation method on all operating systems is npm: + +```bash +npm install -g kdm-cli +``` + +After installation: + +```bash +kdm --help +``` + +You can also run KDM without global installation: + +```bash +npx kdm-cli +``` + +## Install On macOS + +### 1. Install Node.js + +Recommended with Homebrew: + +```bash +brew install node +``` + +Or install Node.js from: + +```text +https://nodejs.org +``` + +### 2. Install Docker + +Install Docker Desktop for macOS: + +```text +https://www.docker.com/products/docker-desktop/ +``` + +Start Docker Desktop, then verify: + +```bash +docker ps +``` + +### 3. Install Kubernetes Tooling + +For local Kubernetes with Minikube: + +```bash +brew install kubectl minikube +minikube start +``` + +Verify: + +```bash +kubectl get pods --all-namespaces +``` + +### 4. Install KDM + +```bash +npm install -g kdm-cli +``` + +### 5. Run KDM + +```bash +kdm +``` + +## Install On Linux + +The exact package manager depends on the distribution. + +### 1. Install Node.js + +Ubuntu or Debian: + +```bash +sudo apt update +sudo apt install -y nodejs npm +``` + +If your distro provides an old Node.js version, install a newer release from NodeSource or use `nvm`. + +Using `nvm`: + +```bash +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +nvm install 20 +nvm use 20 +``` + +### 2. Install Docker + +Ubuntu or Debian: + +```bash +sudo apt update +sudo apt install -y docker.io +sudo systemctl enable --now docker +``` + +Allow your user to run Docker without `sudo`: + +```bash +sudo usermod -aG docker "$USER" +``` + +Then log out and log back in. + +Verify: + +```bash +docker ps +``` + +### 3. Install Kubernetes Tooling + +Install `kubectl` using your distro package manager or the official Kubernetes instructions. + +For Minikube: + +```bash +curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 +sudo install minikube-linux-amd64 /usr/local/bin/minikube +minikube start +``` + +Verify: + +```bash +kubectl get pods --all-namespaces +``` + +### 4. Install KDM + +```bash +npm install -g kdm-cli +``` + +If global npm installs require elevated permissions, prefer fixing npm's global prefix or use `nvm`. + +### 5. Run KDM + +```bash +kdm +``` + +## Install On Windows + +### 1. Install Node.js + +Download and install the LTS version from: + +```text +https://nodejs.org +``` + +Verify in PowerShell: + +```powershell +node --version +npm --version +``` + +### 2. Install Docker Desktop + +Install Docker Desktop for Windows: + +```text +https://www.docker.com/products/docker-desktop/ +``` + +Enable WSL 2 integration if you use WSL-based development. + +Verify: + +```powershell +docker ps +``` + +### 3. Install Kubernetes Tooling + +Docker Desktop can enable a local Kubernetes cluster from its settings. + +Alternatively, install Minikube: + +```powershell +winget install Kubernetes.minikube +winget install Kubernetes.kubectl +minikube start +``` + +Verify: + +```powershell +kubectl get pods --all-namespaces +``` + +### 4. Install KDM + +```powershell +npm install -g kdm-cli +``` + +### 5. Run KDM + +```powershell +kdm +``` + +If PowerShell cannot find `kdm`, restart the terminal and confirm npm's global bin directory is in your `PATH`. + +## Install From Source + +Use this when contributing to KDM or testing local changes. + +```bash +git clone https://github.com/KDM-cli/kdm-cli.git +cd kdm-cli +npm install +npm run build +npm test +``` + +Run the local CLI: + +```bash +node bin/kdm.js --help +``` + +For development: + +```bash +npm run dev +``` + +## First-Time Setup + +After installing, run: + +```bash +kdm +``` + +Then configure notifications if needed: + +```bash +kdm config setup +``` + +For email alerts, set the SMTP password as an environment variable. + +macOS or Linux: + +```bash +export KDM_SMTP_PASSWORD="your-smtp-password" +``` + +Windows PowerShell: + +```powershell +$env:KDM_SMTP_PASSWORD="your-smtp-password" +``` + +Then check config: + +```bash +kdm config list +``` + +## Common Troubleshooting + +### `kdm: command not found` + +The npm global binary directory may not be in your `PATH`. + +Check: + +```bash +npm bin -g +``` + +Then add that directory to your shell profile or reinstall Node using a tool like `nvm`. + +### Docker Shows Disconnected + +Check that Docker is running: + +```bash +docker ps +``` + +On Linux, make sure your user can access the Docker socket: + +```bash +sudo usermod -aG docker "$USER" +``` + +Log out and log back in after changing Docker group membership. + +### Kubernetes Shows Disconnected + +Check your kubeconfig: + +```bash +kubectl config current-context +kubectl get pods --all-namespaces +``` + +If using Minikube: + +```bash +minikube status +minikube start +``` + +### Email Alerts Do Not Send + +Check: + +- SMTP host. +- SMTP port. +- SMTP user. +- Recipient email. +- `KDM_SMTP_PASSWORD` environment variable. +- Provider-specific app password requirements. + +For Gmail, accounts with two-factor authentication usually require an app password. + +### Discord Alerts Do Not Send + +Check: + +- Webhook URL starts with `https://discord.com/api/webhooks/`. +- The webhook still exists. +- The selected Discord channel allows webhook posts. + +## Recommended Daily Workflow + +Start with a quick status check: + +```bash +kdm +``` + +Inspect all workloads: + +```bash +kdm show runners +``` + +Check health: + +```bash +kdm health all +``` + +Watch continuously during development: + +```bash +kdm watch +``` + +Inspect logs when something fails: + +```bash +kdm logs +``` + +## Future Direction + +KDM is being prepared to grow from a monitoring CLI into a more AI-assisted Kubernetes and Docker troubleshooting tool. + +Planned areas include: + +- Kubernetes analyzer engine. +- Docker analyzer engine. +- AI-backed explanations. +- Configurable filters. +- Local and remote cache support. +- Server and MCP modes. + +The current KDM foundation already provides the runtime connections, workload discovery, terminal UI, and alerting needed for those future capabilities. diff --git a/src/__tests__/analysis.test.ts b/src/__tests__/analysis.test.ts index 395f5fe..369f06b 100644 --- a/src/__tests__/analysis.test.ts +++ b/src/__tests__/analysis.test.ts @@ -37,6 +37,17 @@ vi.mock('conf', () => { }; }); +vi.mock('../kubernetes/resources', () => ({ + listPods: vi.fn(async () => []), + listDeployments: vi.fn(async () => []), + listServices: vi.fn(async () => []), + listPersistentVolumeClaims: vi.fn(async () => []), + listNodes: vi.fn(async () => []), + readEndpoints: vi.fn(async () => undefined), + labelsToSelector: (labels: Record = {}) => + Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(','), +})); + describe('Analysis Engine', () => { beforeEach(() => { clearConfig(); diff --git a/src/__tests__/analyze-command.test.ts b/src/__tests__/analyze-command.test.ts new file mode 100644 index 0000000..30ada5c --- /dev/null +++ b/src/__tests__/analyze-command.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerAnalyzeCommand } from '../commands/analyze'; +import { runAnalysis } from '../analysis/analysis'; + +vi.mock('../analysis/analysis', () => ({ + runAnalysis: vi.fn(async () => ({ + errors: [], + status: 'OK', + problems: 0, + results: [], + })), +})); + +vi.mock('../ui/spinner', () => ({ + createSpinner: vi.fn(() => ({ + start: vi.fn(function (this: any) { return this; }), + stop: vi.fn(), + fail: vi.fn(), + })), +})); + +describe('analyze command', () => { + let program: Command; + let logSpy: any; + let errorSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + program = new Command(); + registerAnalyzeCommand(program); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('passes Kubernetes analysis options to runAnalysis', async () => { + await program.parseAsync([ + 'node', + 'test', + 'analyze', + '--namespace', + 'default', + '--selector', + 'app=api', + '--filter', + 'Pod', + '--filter', + 'Deployment', + '--output', + 'json', + '--max-concurrency', + '3', + '--with-stat', + '--with-doc', + '--kubeconfig', + '/tmp/kubeconfig', + '--kubecontext', + 'minikube', + ]); + + expect(runAnalysis).toHaveBeenCalledWith({ + filters: ['Pod', 'Deployment'], + namespace: 'default', + labelSelector: 'app=api', + output: 'json', + maxConcurrency: 3, + withStats: true, + withDocs: true, + kubeconfig: '/tmp/kubeconfig', + kubecontext: 'minikube', + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"status": "OK"')); + }); + + it('reports invalid output formats', async () => { + await program.parseAsync(['node', 'test', 'analyze', '--output', 'yaml']); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Output format must be either')); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); +}); + diff --git a/src/__tests__/kubernetes-analyzers.test.ts b/src/__tests__/kubernetes-analyzers.test.ts new file mode 100644 index 0000000..5c1a957 --- /dev/null +++ b/src/__tests__/kubernetes-analyzers.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + listDeployments, + listNodes, + listPersistentVolumeClaims, + listPods, + listServices, + readEndpoints, +} from '../kubernetes/resources'; +import { + DeploymentAnalyzer, + NodeAnalyzer, + PersistentVolumeClaimAnalyzer, + PodAnalyzer, + ServiceAnalyzer, +} from '../analyzers'; + +vi.mock('../kubernetes/resources', () => ({ + listPods: vi.fn(async () => []), + listDeployments: vi.fn(async () => []), + listServices: vi.fn(async () => []), + listPersistentVolumeClaims: vi.fn(async () => []), + listNodes: vi.fn(async () => []), + readEndpoints: vi.fn(async () => undefined), + labelsToSelector: (labels: Record = {}) => + Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(','), +})); + +describe('Kubernetes analyzers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects pod CrashLoopBackOff and high restarts', async () => { + vi.mocked(listPods).mockResolvedValueOnce([ + { + metadata: { name: 'api', namespace: 'default' }, + status: { + phase: 'Running', + containerStatuses: [ + { + name: 'api', + ready: false, + restartCount: 5, + state: { waiting: { reason: 'CrashLoopBackOff', message: 'back-off restarting failed container' } }, + }, + ], + }, + } as any, + ]); + + const results = await PodAnalyzer.analyze({}); + + expect(results).toHaveLength(1); + expect(results[0].kind).toBe('Pod'); + expect(results[0].name).toBe('api'); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('CrashLoopBackOff'); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('restarted 5 times'); + }); + + it('returns no pod result for healthy pods', async () => { + vi.mocked(listPods).mockResolvedValueOnce([ + { + metadata: { name: 'api', namespace: 'default' }, + status: { + phase: 'Running', + containerStatuses: [{ name: 'api', ready: true, restartCount: 0, state: { running: {} } }], + }, + } as any, + ]); + + await expect(PodAnalyzer.analyze({})).resolves.toEqual([]); + }); + + it('detects deployments with unavailable replicas and progress deadline failures', async () => { + vi.mocked(listDeployments).mockResolvedValueOnce([ + { + metadata: { name: 'web', namespace: 'default' }, + spec: { replicas: 3 }, + status: { + availableReplicas: 1, + unavailableReplicas: 2, + conditions: [ + { type: 'Progressing', status: 'False', reason: 'ProgressDeadlineExceeded', message: 'rollout stuck' }, + ], + }, + } as any, + ]); + + const results = await DeploymentAnalyzer.analyze({}); + + expect(results).toHaveLength(1); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('1/3 available replicas'); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('progress deadline'); + }); + + it('detects services with no matching pods and no endpoints', async () => { + vi.mocked(listServices).mockResolvedValueOnce([ + { + metadata: { name: 'api-service', namespace: 'default' }, + spec: { selector: { app: 'api' } }, + } as any, + ]); + vi.mocked(listPods).mockResolvedValueOnce([]); + vi.mocked(readEndpoints).mockResolvedValueOnce({ subsets: [] } as any); + + const results = await ServiceAnalyzer.analyze({}); + + expect(results).toHaveLength(1); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('matches no pods'); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('no ready endpoints'); + }); + + it('detects service with unresolved target port', async () => { + vi.mocked(listServices).mockResolvedValueOnce([ + { + metadata: { name: 'api-service', namespace: 'default' }, + spec: { + selector: { app: 'api' }, + ports: [ + { port: 80, targetPort: 'http-port' }, + { port: 8080, targetPort: 9090 }, + ], + }, + } as any, + ]); + vi.mocked(listPods).mockResolvedValueOnce([ + { + metadata: { name: 'api-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'api-container', + ports: [ + { containerPort: 8080, name: 'other-port' }, + ], + }, + ], + }, + } as any, + ]); + vi.mocked(readEndpoints).mockResolvedValueOnce({ + subsets: [{ addresses: [{ ip: '10.0.0.1' }] }], + } as any); + + const results = await ServiceAnalyzer.analyze({}); + + expect(results).toHaveLength(1); + const errors = results[0].errors.map((error) => error.text).join('\n'); + expect(errors).toContain("Service target port 'http-port' appears unresolved"); + expect(errors).toContain('Service target port 9090 appears unresolved'); + }); + + it('detects pending PVCs without a storage class and status failure conditions', async () => { + vi.mocked(listPersistentVolumeClaims).mockResolvedValueOnce([ + { + metadata: { name: 'data', namespace: 'default' }, + spec: {}, + status: { + phase: 'Pending', + conditions: [ + { + type: 'VolumeBinding', + status: 'False', + reason: 'VolumeBindingFailed', + message: 'failed to bind volume: no persistent volumes available', + }, + ], + }, + } as any, + ]); + + const results = await PersistentVolumeClaimAnalyzer.analyze({}); + + expect(results).toHaveLength(1); + const errors = results[0].errors.map((error) => error.text).join('\n'); + expect(errors).toContain('Pending'); + expect(errors).toContain('without a storage class'); + expect(errors).toContain('VolumeBindingFailed'); + expect(errors).toContain('failed to bind volume'); + }); + + it('detects NotReady and pressured nodes', async () => { + vi.mocked(listNodes).mockResolvedValueOnce([ + { + metadata: { name: 'worker-1' }, + spec: { unschedulable: true }, + status: { + conditions: [ + { type: 'Ready', status: 'False', reason: 'KubeletNotReady', message: 'runtime down' }, + { type: 'DiskPressure', status: 'True', message: 'disk full' }, + ], + }, + } as any, + ]); + + const results = await NodeAnalyzer.analyze({}); + + expect(results).toHaveLength(1); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('not Ready'); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('DiskPressure'); + expect(results[0].errors.map((error) => error.text).join('\n')).toContain('unschedulable'); + }); +}); + diff --git a/src/analysis/analysis.ts b/src/analysis/analysis.ts index 7eaadf9..c8d44db 100644 --- a/src/analysis/analysis.ts +++ b/src/analysis/analysis.ts @@ -59,6 +59,8 @@ export async function runAnalysis(options: AnalysisOptions): Promise deployment.metadata?.name ?? 'unknown-deployment'; +const deploymentNamespace = (deployment: k8s.V1Deployment) => deployment.metadata?.namespace ?? 'default'; + +const getDeploymentFailures = (deployment: k8s.V1Deployment): Failure[] => { + const failures: Failure[] = []; + const desired = deployment.spec?.replicas ?? 1; + const available = deployment.status?.availableReplicas ?? 0; + const unavailable = deployment.status?.unavailableReplicas ?? Math.max(desired - available, 0); + + if (desired > available) { + failures.push({ text: `Deployment has ${available}/${desired} available replicas` }); + } + + if (unavailable > 0) { + failures.push({ text: `Deployment has ${unavailable} unavailable replica${unavailable === 1 ? '' : 's'}` }); + } + + for (const condition of deployment.status?.conditions ?? []) { + if (condition.type === 'Progressing' && condition.reason === 'ProgressDeadlineExceeded') { + failures.push({ + text: `Deployment rollout exceeded progress deadline${condition.message ? `: ${condition.message}` : ''}`, + }); + } + if (condition.status === 'False' && condition.message) { + failures.push({ text: `Deployment condition ${condition.type} is False: ${condition.message}` }); + } + } + + return failures; +}; + +export const DeploymentAnalyzer: Analyzer = { + name: 'Deployment', + async analyze(context: AnalyzerContext): Promise { + const deployments = await listDeployments(context); + return deployments.flatMap((deployment) => { + const errors = getDeploymentFailures(deployment); + if (!errors.length) return []; + return [{ + kind: 'Deployment', + name: deploymentName(deployment), + namespace: deploymentNamespace(deployment), + errors, + }]; + }); + }, +}; + diff --git a/src/analyzers/index.ts b/src/analyzers/index.ts index 0896851..ec93a9f 100644 --- a/src/analyzers/index.ts +++ b/src/analyzers/index.ts @@ -1,4 +1,9 @@ import { Analyzer } from './types'; +import { PodAnalyzer } from './pod'; +import { DeploymentAnalyzer } from './deployment'; +import { ServiceAnalyzer } from './service'; +import { PersistentVolumeClaimAnalyzer } from './pvc'; +import { NodeAnalyzer } from './node'; class AnalyzerRegistry { private analyzers = new Map(); @@ -26,35 +31,17 @@ class AnalyzerRegistry { export const registry = new AnalyzerRegistry(); -// Core no-op placeholder analyzers -export const PodAnalyzer: Analyzer = { - name: 'Pod', - analyze: async (_context) => [], -}; - -export const DeploymentAnalyzer: Analyzer = { - name: 'Deployment', - analyze: async (_context) => [], -}; - -export const ServiceAnalyzer: Analyzer = { - name: 'Service', - analyze: async (_context) => [], -}; - -export const PersistentVolumeClaimAnalyzer: Analyzer = { - name: 'PersistentVolumeClaim', - analyze: async (_context) => [], -}; - -export const NodeAnalyzer: Analyzer = { - name: 'Node', - analyze: async (_context) => [], -}; - // Register initial core analyzers registry.register(PodAnalyzer); registry.register(DeploymentAnalyzer); registry.register(ServiceAnalyzer); registry.register(PersistentVolumeClaimAnalyzer); registry.register(NodeAnalyzer); + +export { + PodAnalyzer, + DeploymentAnalyzer, + ServiceAnalyzer, + PersistentVolumeClaimAnalyzer, + NodeAnalyzer, +}; diff --git a/src/analyzers/node.ts b/src/analyzers/node.ts new file mode 100644 index 0000000..8018d40 --- /dev/null +++ b/src/analyzers/node.ts @@ -0,0 +1,49 @@ +import type * as k8s from '@kubernetes/client-node'; +import type { Analyzer, AnalyzerContext, AnalyzerResult, Failure } from './types'; +import { listNodes } from '../kubernetes/resources'; + +const PRESSURE_CONDITIONS = new Set(['MemoryPressure', 'DiskPressure', 'PIDPressure', 'NetworkUnavailable']); + +const nodeName = (node: k8s.V1Node) => node.metadata?.name ?? 'unknown-node'; + +const getNodeFailures = (node: k8s.V1Node): Failure[] => { + const failures: Failure[] = []; + const ready = node.status?.conditions?.find((condition) => condition.type === 'Ready'); + + if (ready?.status !== 'True') { + failures.push({ + text: `Node is not Ready${ready?.reason ? `: ${ready.reason}` : ''}${ready?.message ? ` - ${ready.message}` : ''}`, + }); + } + + for (const condition of node.status?.conditions ?? []) { + if (PRESSURE_CONDITIONS.has(condition.type) && condition.status === 'True') { + failures.push({ + text: `Node condition ${condition.type} is True${condition.message ? `: ${condition.message}` : ''}`, + }); + } + } + + if (node.spec?.unschedulable) { + failures.push({ text: 'Node is marked unschedulable' }); + } + + return failures; +}; + +export const NodeAnalyzer: Analyzer = { + name: 'Node', + async analyze(context: AnalyzerContext): Promise { + const nodes = await listNodes(context); + return nodes.flatMap((node) => { + const errors = getNodeFailures(node); + if (!errors.length) return []; + return [{ + kind: 'Node', + name: nodeName(node), + errors, + }]; + }); + }, +}; + diff --git a/src/analyzers/pod.ts b/src/analyzers/pod.ts new file mode 100644 index 0000000..df3152f --- /dev/null +++ b/src/analyzers/pod.ts @@ -0,0 +1,69 @@ +import type * as k8s from '@kubernetes/client-node'; +import type { Analyzer, AnalyzerContext, AnalyzerResult, Failure } from './types'; +import { listPods } from '../kubernetes/resources'; + +const RESTART_WARNING_THRESHOLD = 3; +const WAITING_FAILURE_REASONS = new Set([ + 'CrashLoopBackOff', + 'ImagePullBackOff', + 'ErrImagePull', + 'CreateContainerConfigError', +]); + +const podName = (pod: k8s.V1Pod) => pod.metadata?.name ?? 'unknown-pod'; +const podNamespace = (pod: k8s.V1Pod) => pod.metadata?.namespace ?? 'default'; + +const getPodFailures = (pod: k8s.V1Pod): Failure[] => { + const failures: Failure[] = []; + const phase = pod.status?.phase; + + if (phase === 'Failed') { + failures.push({ text: `Pod phase is Failed${pod.status?.reason ? `: ${pod.status.reason}` : ''}` }); + } + + if (phase === 'Pending') { + const scheduled = pod.status?.conditions?.find((condition) => condition.type === 'PodScheduled'); + if (scheduled?.status === 'False') { + failures.push({ + text: `Pod is pending and unschedulable${scheduled.reason ? `: ${scheduled.reason}` : ''}${scheduled.message ? ` - ${scheduled.message}` : ''}`, + }); + } + } + + for (const status of pod.status?.containerStatuses ?? []) { + const waiting = status.state?.waiting; + if (waiting?.reason && WAITING_FAILURE_REASONS.has(waiting.reason)) { + failures.push({ + text: `Container ${status.name} is waiting in ${waiting.reason}${waiting.message ? `: ${waiting.message}` : ''}`, + }); + } + + if (!status.ready) { + failures.push({ text: `Container ${status.name} is not ready` }); + } + + if ((status.restartCount ?? 0) > RESTART_WARNING_THRESHOLD) { + failures.push({ text: `Container ${status.name} restarted ${status.restartCount} times` }); + } + } + + return failures; +}; + +export const PodAnalyzer: Analyzer = { + name: 'Pod', + async analyze(context: AnalyzerContext): Promise { + const pods = await listPods(context); + return pods.flatMap((pod) => { + const errors = getPodFailures(pod); + if (!errors.length) return []; + return [{ + kind: 'Pod', + name: podName(pod), + namespace: podNamespace(pod), + errors, + }]; + }); + }, +}; + diff --git a/src/analyzers/pvc.ts b/src/analyzers/pvc.ts new file mode 100644 index 0000000..043f449 --- /dev/null +++ b/src/analyzers/pvc.ts @@ -0,0 +1,50 @@ +import type * as k8s from '@kubernetes/client-node'; +import type { Analyzer, AnalyzerContext, AnalyzerResult, Failure } from './types'; +import { listPersistentVolumeClaims } from '../kubernetes/resources'; + +const pvcName = (pvc: k8s.V1PersistentVolumeClaim) => pvc.metadata?.name ?? 'unknown-pvc'; +const pvcNamespace = (pvc: k8s.V1PersistentVolumeClaim) => pvc.metadata?.namespace ?? 'default'; + +const getPvcFailures = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => { + const failures: Failure[] = []; + const phase = pvc.status?.phase; + + if (phase === 'Pending') { + failures.push({ text: 'PersistentVolumeClaim is Pending' }); + if (!pvc.spec?.storageClassName) { + failures.push({ text: 'PersistentVolumeClaim is pending without a storage class' }); + } + } + + if (phase === 'Lost') { + failures.push({ text: 'PersistentVolumeClaim is Lost' }); + } + + for (const condition of pvc.status?.conditions ?? []) { + if (condition.message) { + failures.push({ + text: `PersistentVolumeClaim condition ${condition.type} is ${condition.status}${condition.reason ? ` (${condition.reason})` : ''}: ${condition.message}`, + }); + } + } + + return failures; +}; + +export const PersistentVolumeClaimAnalyzer: Analyzer = { + name: 'PersistentVolumeClaim', + async analyze(context: AnalyzerContext): Promise { + const pvcs = await listPersistentVolumeClaims(context); + return pvcs.flatMap((pvc) => { + const errors = getPvcFailures(pvc); + if (!errors.length) return []; + return [{ + kind: 'PersistentVolumeClaim', + name: pvcName(pvc), + namespace: pvcNamespace(pvc), + errors, + }]; + }); + }, +}; + diff --git a/src/analyzers/service.ts b/src/analyzers/service.ts new file mode 100644 index 0000000..29c9810 --- /dev/null +++ b/src/analyzers/service.ts @@ -0,0 +1,98 @@ +import type * as k8s from '@kubernetes/client-node'; +import type { Analyzer, AnalyzerContext, AnalyzerResult, Failure } from './types'; +import { labelsToSelector, listPods, listServices, readEndpoints } from '../kubernetes/resources'; + +const serviceName = (service: k8s.V1Service) => service.metadata?.name ?? 'unknown-service'; +const serviceNamespace = (service: k8s.V1Service) => service.metadata?.namespace ?? 'default'; + +const endpointsAreEmpty = (endpoints?: k8s.V1Endpoints) => + !endpoints?.subsets?.some((subset) => (subset.addresses?.length ?? 0) > 0); + +const getServiceFailures = async ( + service: k8s.V1Service, + context: AnalyzerContext, +): Promise => { + const failures: Failure[] = []; + const namespace = serviceNamespace(service); + const name = serviceName(service); + + if (service.spec?.type === 'ExternalName') { + return failures; + } + + const selector = labelsToSelector(service.spec?.selector); + let matchingPods: k8s.V1Pod[] = []; + if (selector) { + matchingPods = await listPods({ ...context, namespace, labelSelector: selector }); + if (!matchingPods.length) { + failures.push({ text: `Service selector matches no pods (${selector})` }); + } + } + + const endpoints = await readEndpoints(name, namespace, context); + if (endpointsAreEmpty(endpoints)) { + failures.push({ text: 'Service has no ready endpoints' }); + } + + if (selector && matchingPods.length > 0) { + const ports = service.spec?.ports ?? []; + for (const port of ports) { + const targetPort = port.targetPort ?? port.port; + let resolved = false; + + if (typeof targetPort === 'string') { + resolved = matchingPods.some((pod) => + pod.spec?.containers?.some((container) => + container.ports?.some((cp) => cp.name === targetPort), + ), + ); + if (!resolved) { + failures.push({ + text: `Service target port '${targetPort}' appears unresolved (no matching container port name found in pods)`, + }); + } + } else if (typeof targetPort === 'number') { + const hasDeclaredPorts = matchingPods.some((pod) => + pod.spec?.containers?.some((container) => (container.ports?.length ?? 0) > 0), + ); + if (hasDeclaredPorts) { + resolved = matchingPods.some((pod) => + pod.spec?.containers?.some((container) => + container.ports?.some((cp) => cp.containerPort === targetPort), + ), + ); + if (!resolved) { + failures.push({ + text: `Service target port ${targetPort} appears unresolved (no matching containerPort found in pods)`, + }); + } + } + } + } + } + + return failures; +}; + +export const ServiceAnalyzer: Analyzer = { + name: 'Service', + async analyze(context: AnalyzerContext): Promise { + const services = await listServices(context); + const results: AnalyzerResult[] = []; + + for (const service of services) { + const errors = await getServiceFailures(service, context); + if (errors.length) { + results.push({ + kind: 'Service', + name: serviceName(service), + namespace: serviceNamespace(service), + errors, + }); + } + } + + return results; + }, +}; + diff --git a/src/analyzers/types.ts b/src/analyzers/types.ts index 8a1895f..f95225a 100644 --- a/src/analyzers/types.ts +++ b/src/analyzers/types.ts @@ -6,6 +6,8 @@ export interface Analyzer { export interface AnalyzerContext { namespace?: string; labelSelector?: string; + kubeconfig?: string; + kubecontext?: string; withDocs?: boolean; } diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts new file mode 100644 index 0000000..6dd25df --- /dev/null +++ b/src/commands/analyze.ts @@ -0,0 +1,60 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { runAnalysis } from '../analysis/analysis'; +import { formatJsonOutput, formatTextOutput } from '../analysis/output'; +import type { AnalysisOptions } from '../analysis/types'; +import { createSpinner } from '../ui/spinner'; + +const collectFilter = (value: string, previous: string[]) => [...previous, value]; + +const parseOutput = (output: string): AnalysisOptions['output'] => { + if (output === 'json' || output === 'text') return output; + throw new Error('Output format must be either "text" or "json"'); +}; + +export const registerAnalyzeCommand = (program: Command) => { + program + .command('analyze') + .alias('analyse') + .description('Analyze Kubernetes resources for common workload problems') + .option('-n, --namespace ', 'Namespace to analyze') + .option('-L, --selector ', 'Label selector to filter Kubernetes resources') + .option('-f, --filter ', 'Analyzer filter to run, such as Pod or Deployment', collectFilter, []) + .option('-o, --output ', 'Output format: text or json', 'text') + .option('-m, --max-concurrency ', 'Maximum number of analyzers to run concurrently', '10') + .option('-s, --with-stat', 'Print analyzer execution stats') + .option('--with-doc', 'Reserve Kubernetes documentation lookup for analyzer output') + .option('--kubeconfig ', 'Path to kubeconfig file') + .option('--kubecontext ', 'Kubernetes context to use') + .action(async (options) => { + let output: AnalysisOptions['output'] = 'text'; + let spinner: ReturnType | null = null; + + try { + output = parseOutput(options.output); + spinner = output === 'json' ? null : createSpinner('Analyzing Kubernetes resources...').start(); + const result = await runAnalysis({ + filters: options.filter.length ? options.filter : undefined, + namespace: options.namespace, + labelSelector: options.selector, + output, + maxConcurrency: Number.parseInt(options.maxConcurrency, 10), + withStats: Boolean(options.withStat), + withDocs: Boolean(options.withDoc), + kubeconfig: options.kubeconfig, + kubecontext: options.kubecontext, + }); + + spinner?.stop('Analysis complete'); + console.log(output === 'json' ? formatJsonOutput(result) : formatTextOutput(result)); + } catch (error) { + spinner?.fail(`Analysis failed: ${(error as Error).message}`); + if (output === 'json') { + console.error(JSON.stringify({ error: (error as Error).message }, null, 2)); + } else { + console.error(chalk.red(`Analysis failed: ${(error as Error).message}`)); + } + process.exitCode = 1; + } + }); +}; diff --git a/src/commands/root.ts b/src/commands/root.ts index 39497ae..44e5eb2 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -8,6 +8,7 @@ import { registerHealthCommand } from './health'; import { registerWatchCommand } from './watch'; import { registerLogsCommand } from './logs'; import { registerConfigCommand } from './config'; +import { registerAnalyzeCommand } from './analyze'; import { logger } from '../utils/logger'; import { showWelcomeBanner } from '../ui/banner'; import { createSpinner } from '../ui/spinner'; @@ -24,6 +25,7 @@ registerHealthCommand(program); registerWatchCommand(program); registerLogsCommand(program); registerConfigCommand(program); +registerAnalyzeCommand(program); const run = async () => { if (!process.argv.slice(2).length) { @@ -83,4 +85,3 @@ const run = async () => { run(); - diff --git a/src/kubernetes/client.ts b/src/kubernetes/client.ts index 0aafd97..a2ec433 100644 --- a/src/kubernetes/client.ts +++ b/src/kubernetes/client.ts @@ -2,27 +2,56 @@ import * as k8s from '@kubernetes/client-node'; let kc: k8s.KubeConfig | null = null; let k8sApi: k8s.CoreV1Api | null = null; +let appsApi: k8s.AppsV1Api | null = null; +let clientKey = ''; -export const getKubeConfig = (): k8s.KubeConfig => { - if (!kc) { +export interface KubernetesClientOptions { + kubeconfig?: string; + kubecontext?: string; +} + +const getClientKey = (options: KubernetesClientOptions = {}) => + `${options.kubeconfig ?? 'default'}::${options.kubecontext ?? 'current'}`; + +export const getKubeConfig = (options: KubernetesClientOptions = {}): k8s.KubeConfig => { + const nextKey = getClientKey(options); + if (!kc || clientKey !== nextKey) { kc = new k8s.KubeConfig(); try { - kc.loadFromDefault(); + if (options.kubeconfig) { + kc.loadFromFile(options.kubeconfig); + } else { + kc.loadFromDefault(); + } + if (options.kubecontext) { + kc.setCurrentContext(options.kubecontext); + } } catch (e) { // Failed to load default config } + clientKey = nextKey; + k8sApi = null; + appsApi = null; } return kc; }; -export const getK8sApi = (): k8s.CoreV1Api => { +export const getK8sApi = (options: KubernetesClientOptions = {}): k8s.CoreV1Api => { if (!k8sApi) { - const config = getKubeConfig(); + const config = getKubeConfig(options); k8sApi = config.makeApiClient(k8s.CoreV1Api); } return k8sApi; }; +export const getAppsApi = (options: KubernetesClientOptions = {}): k8s.AppsV1Api => { + if (!appsApi) { + const config = getKubeConfig(options); + appsApi = config.makeApiClient(k8s.AppsV1Api); + } + return appsApi; +}; + export const checkK8sConnection = async (): Promise<{ connected: boolean; podCount: number }> => { try { const api = getK8sApi(); diff --git a/src/kubernetes/resources.ts b/src/kubernetes/resources.ts new file mode 100644 index 0000000..63434ef --- /dev/null +++ b/src/kubernetes/resources.ts @@ -0,0 +1,86 @@ +import type * as k8s from '@kubernetes/client-node'; +import { getAppsApi, getK8sApi, type KubernetesClientOptions } from './client'; + +export interface KubernetesResourceOptions extends KubernetesClientOptions { + namespace?: string; + labelSelector?: string; +} + +type K8sList = { items?: T[] }; +type K8sResponse = T | { body: T }; + +const unwrap = (response: K8sResponse): T => + response && typeof response === 'object' && 'body' in response + ? (response as { body: T }).body + : (response as T); + +const items = (response: K8sResponse>): T[] => unwrap(response).items ?? []; + +export const listPods = async (options: KubernetesResourceOptions = {}): Promise => { + const api = getK8sApi(options); + const response = options.namespace + ? await api.listNamespacedPod(options.namespace, undefined, undefined, undefined, undefined, options.labelSelector) + : await api.listPodForAllNamespaces(undefined, undefined, undefined, options.labelSelector); + return items(response); +}; + +export const listDeployments = async ( + options: KubernetesResourceOptions = {}, +): Promise => { + const api = getAppsApi(options); + const response = options.namespace + ? await api.listNamespacedDeployment(options.namespace, undefined, undefined, undefined, undefined, options.labelSelector) + : await api.listDeploymentForAllNamespaces(undefined, undefined, undefined, options.labelSelector); + return items(response); +}; + +export const listServices = async ( + options: KubernetesResourceOptions = {}, +): Promise => { + const api = getK8sApi(options); + const response = options.namespace + ? await api.listNamespacedService(options.namespace, undefined, undefined, undefined, undefined, options.labelSelector) + : await api.listServiceForAllNamespaces(undefined, undefined, undefined, options.labelSelector); + return items(response); +}; + +export const listPersistentVolumeClaims = async ( + options: KubernetesResourceOptions = {}, +): Promise => { + const api = getK8sApi(options); + const response = options.namespace + ? await api.listNamespacedPersistentVolumeClaim( + options.namespace, + undefined, + undefined, + undefined, + undefined, + options.labelSelector, + ) + : await api.listPersistentVolumeClaimForAllNamespaces(undefined, undefined, undefined, options.labelSelector); + return items(response); +}; + +export const listNodes = async (options: KubernetesResourceOptions = {}): Promise => { + const api = getK8sApi(options); + const response = await api.listNode(undefined, undefined, undefined, options.labelSelector); + return items(response); +}; + +export const readEndpoints = async ( + name: string, + namespace: string, + options: KubernetesResourceOptions = {}, +): Promise => { + const api = getK8sApi(options); + try { + return unwrap(await api.readNamespacedEndpoints(name, namespace)); + } catch { + return undefined; + } +}; + +export const labelsToSelector = (labels: Record = {}) => + Object.entries(labels) + .map(([key, value]) => `${key}=${value}`) + .join(','); From 24ac05f495dc2370a97d0f4a2f9fc37ad6903d50 Mon Sep 17 00:00:00 2001 From: Utkarsh patrikar <137105846+utkarsh232005@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:52:30 +0530 Subject: [PATCH 2/7] Update src/kubernetes/client.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/kubernetes/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/kubernetes/client.ts b/src/kubernetes/client.ts index a2ec433..82ed820 100644 --- a/src/kubernetes/client.ts +++ b/src/kubernetes/client.ts @@ -26,8 +26,8 @@ export const getKubeConfig = (options: KubernetesClientOptions = {}): k8s.KubeCo if (options.kubecontext) { kc.setCurrentContext(options.kubecontext); } - } catch (e) { - // Failed to load default config + } catch (e: any) { + throw new Error(`Failed to load kubeconfig: ${e?.message || String(e)}`); } clientKey = nextKey; k8sApi = null; From afea3fa8b9ded7fcc4de83872fe3ac625c8e45f4 Mon Sep 17 00:00:00 2001 From: Utkarsh patrikar <137105846+utkarsh232005@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:53:06 +0530 Subject: [PATCH 3/7] Update src/analyzers/pvc.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/analyzers/pvc.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/analyzers/pvc.ts b/src/analyzers/pvc.ts index 043f449..0d6c666 100644 --- a/src/analyzers/pvc.ts +++ b/src/analyzers/pvc.ts @@ -10,9 +10,10 @@ const getPvcFailures = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => { const phase = pvc.status?.phase; if (phase === 'Pending') { - failures.push({ text: 'PersistentVolumeClaim is Pending' }); if (!pvc.spec?.storageClassName) { failures.push({ text: 'PersistentVolumeClaim is pending without a storage class' }); + } else { + failures.push({ text: 'PersistentVolumeClaim is Pending' }); } } From 54daa7abe9146af6991486bde9ed45ac2589f497 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 7 Jun 2026 13:04:12 +0530 Subject: [PATCH 4/7] feat: address PR comments, consolidate replica check, optimize service analyzer, and add JSDocs --- .DS_Store | Bin 0 -> 10244 bytes src/__tests__/analyze-command.test.ts | 65 ++++++- src/__tests__/kubernetes-analyzers.test.ts | 35 +++- src/analysis/analysis.ts | 31 ++++ src/analysis/types.ts | 1 + src/analyzers/deployment.ts | 28 ++- src/analyzers/node.ts | 54 +++++- src/analyzers/pod.ts | 70 +++++-- src/analyzers/pvc.ts | 66 +++++-- src/analyzers/service.ts | 204 +++++++++++++++------ src/analyzers/types.ts | 1 + src/commands/analyze.ts | 32 +++- src/kubernetes/client.ts | 32 ++++ src/kubernetes/resources.ts | 68 ++++++- 14 files changed, 577 insertions(+), 110 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1d9c1a979f8ffedede943f48f9b6c32aab8def60 GIT binary patch literal 10244 zcmeHMO>7%Q6n+yMoHQ=kq)DMgR91axrHU$razu4qn_?B3D0Zs2Kcx10z1e2HW9_cf zpOa4JDy#1+A*S1yPPy>Q})5UAqNTYKe1znNJ#>$M$vDYVs$H1lTPy!YnK=Qr~- zVPVT2I7dJk^vShPRZZUMj!0^TMnEH=5zq)|1T+HA z3j)a5qD;>!s#}eKMnEI*1OahAN6c&XHMc@w}}xcpHQXDU|!my9yNz% z|NgW3s6^a{FEi%5JNKmz2|FFW>&axPA%A}RM);GWCQ$GyE;plLcdh#DxgwR0B zF?YI3iv7~;x=s)-FaKc}Y$}z0<<-pe%)Z&|{@3P=`Go`Xi}OoMO9x+np`ec_?yWNG%;x0p) zJMUYTC+)4Y+kq8s#QOD3V=gd#?z*cya6*TB^{c@azgY>bPQ`0;%(&xNH&?o1D7a+# z0qBU%M!n(t4bQfUx39U4rsb}9MIMH{v*DQBQ=ZRBv-n)~EnzZ$=8*)C@`Ve7&T-W- zD6cvQti?euqAdTT*O&d8$31H%UszQmGe{RF=q!CqmuQ>r(C73OeM>*kJ^Gy<(%&q@ z40e>AV5iyp>B(^~Yo1lVlEF*-g zR8EA-{Y0o_$wNrB$U@k6WUogqz7ippT6sTh66~7;T$#r?C)TlmI>n`SrYo)LXpUmOkT^?9*sr?i} z&J^pVHVz+q()#6L71zXcx@ZJ60vZ90fJQ(g@XQcMDa$$W{$HN_|Nk?aUmMm4XaxR$ z1X#LMEfwL%;7ugyl6WV`pHGh`z8Ad6_y5PTwCyec literal 0 HcmV?d00001 diff --git a/src/__tests__/analyze-command.test.ts b/src/__tests__/analyze-command.test.ts index 30ada5c..ab9b822 100644 --- a/src/__tests__/analyze-command.test.ts +++ b/src/__tests__/analyze-command.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Command } from 'commander'; import { registerAnalyzeCommand } from '../commands/analyze'; import { runAnalysis } from '../analysis/analysis'; @@ -20,6 +20,17 @@ vi.mock('../ui/spinner', () => ({ })), })); +vi.mock('../utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + dim: vi.fn(), + newline: vi.fn(), + }, +})); + describe('analyze command', () => { let program: Command; let logSpy: any; @@ -33,6 +44,10 @@ describe('analyze command', () => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { + process.exitCode = undefined; + }); + it('passes Kubernetes analysis options to runAnalysis', async () => { await program.parseAsync([ 'node', @@ -58,7 +73,7 @@ describe('analyze command', () => { 'minikube', ]); - expect(runAnalysis).toHaveBeenCalledWith({ + expect(runAnalysis).toHaveBeenCalledWith(expect.objectContaining({ filters: ['Pod', 'Deployment'], namespace: 'default', labelSelector: 'app=api', @@ -68,16 +83,54 @@ describe('analyze command', () => { withDocs: true, kubeconfig: '/tmp/kubeconfig', kubecontext: 'minikube', - }); + })); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"status": "OK"')); }); it('reports invalid output formats', async () => { + const { logger } = await import('../utils/logger'); await program.parseAsync(['node', 'test', 'analyze', '--output', 'yaml']); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Output format must be either')); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Output format must be either')); expect(process.exitCode).toBe(1); - process.exitCode = undefined; }); -}); + it('handles runAnalysis failures gracefully', async () => { + vi.mocked(runAnalysis).mockRejectedValueOnce(new Error('K8s connection failed')); + await program.parseAsync(['node', 'test', 'analyze']); + + expect(process.exitCode).toBe(1); + }); + + it('handles ProblemDetected status output', async () => { + vi.mocked(runAnalysis).mockResolvedValueOnce({ + errors: [], + status: 'ProblemDetected', + problems: 2, + results: [ + { + kind: 'Pod', + name: 'broken-pod', + namespace: 'default', + errors: [{ text: 'CrashLoopBackOff' }, { text: 'Container not ready' }], + }, + ], + }); + await program.parseAsync(['node', 'test', 'analyze', '--output', 'json']); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"status": "ProblemDetected"')); + }); + + it('runs with default options when no flags provided', async () => { + await program.parseAsync(['node', 'test', 'analyze']); + + expect(runAnalysis).toHaveBeenCalledWith(expect.objectContaining({ + filters: undefined, + namespace: undefined, + output: 'text', + maxConcurrency: 10, + withStats: false, + withDocs: false, + })); + }); +}); diff --git a/src/__tests__/kubernetes-analyzers.test.ts b/src/__tests__/kubernetes-analyzers.test.ts index 5c1a957..8c0809d 100644 --- a/src/__tests__/kubernetes-analyzers.test.ts +++ b/src/__tests__/kubernetes-analyzers.test.ts @@ -126,7 +126,7 @@ describe('Kubernetes analyzers', () => { ]); vi.mocked(listPods).mockResolvedValueOnce([ { - metadata: { name: 'api-pod', namespace: 'default' }, + metadata: { name: 'api-pod', namespace: 'default', labels: { app: 'api' } }, spec: { containers: [ { @@ -174,8 +174,7 @@ describe('Kubernetes analyzers', () => { expect(results).toHaveLength(1); const errors = results[0].errors.map((error) => error.text).join('\n'); - expect(errors).toContain('Pending'); - expect(errors).toContain('without a storage class'); + expect(errors).toContain('pending without a storage class'); expect(errors).toContain('VolumeBindingFailed'); expect(errors).toContain('failed to bind volume'); }); @@ -203,3 +202,33 @@ describe('Kubernetes analyzers', () => { }); }); +describe('Kubernetes analyzers - API failure handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('propagates listPods API failures', async () => { + vi.mocked(listPods).mockRejectedValueOnce(new Error('API timeout')); + await expect(PodAnalyzer.analyze({})).rejects.toThrow('API timeout'); + }); + + it('propagates listDeployments API failures', async () => { + vi.mocked(listDeployments).mockRejectedValueOnce(new Error('API timeout')); + await expect(DeploymentAnalyzer.analyze({})).rejects.toThrow('API timeout'); + }); + + it('handles listServices API failures gracefully via Promise.allSettled', async () => { + vi.mocked(listServices).mockRejectedValueOnce(new Error('API timeout')); + await expect(ServiceAnalyzer.analyze({})).rejects.toThrow('API timeout'); + }); + + it('propagates listPersistentVolumeClaims API failures', async () => { + vi.mocked(listPersistentVolumeClaims).mockRejectedValueOnce(new Error('API timeout')); + await expect(PersistentVolumeClaimAnalyzer.analyze({})).rejects.toThrow('API timeout'); + }); + + it('propagates listNodes API failures', async () => { + vi.mocked(listNodes).mockRejectedValueOnce(new Error('API timeout')); + await expect(NodeAnalyzer.analyze({})).rejects.toThrow('API timeout'); + }); +}); diff --git a/src/analysis/analysis.ts b/src/analysis/analysis.ts index c8d44db..a6f27a0 100644 --- a/src/analysis/analysis.ts +++ b/src/analysis/analysis.ts @@ -8,6 +8,12 @@ const DEFAULT_FILTERS = ['Pod', 'Deployment', 'Service', 'PersistentVolumeClaim' const MAX_ALLOWED_CONCURRENCY = 100; const DEFAULT_CONCURRENCY = 10; +/** + * Resolves the list of filters to be run based on option inputs, default configuration, + * or active filters stored in the client settings. + * @param options Options passed to the analysis run. + * @returns An array of string filter names. + */ function resolveFilters(options: AnalysisOptions): string[] { if (options.filters?.length) { return options.filters; @@ -16,6 +22,13 @@ function resolveFilters(options: AnalysisOptions): string[] { return active.length > 0 ? active : DEFAULT_FILTERS; } +/** + * Resolves filter strings to their corresponding Analyzer implementations from the registry. + * Appends error messages to the errors array if a filter name is unrecognized. + * @param filters The names of the filters to resolve. + * @param errors The array of error strings to log unknown filters. + * @returns Resolved Analyzer instances. + */ function resolveAnalyzers(filters: string[], errors: string[]): Analyzer[] { const analyzers: Analyzer[] = []; for (const filter of filters) { @@ -29,6 +42,11 @@ function resolveAnalyzers(filters: string[], errors: string[]): Analyzer[] { return analyzers; } +/** + * Parses and bounds the concurrency limit within the minimum and maximum constraints. + * @param maxConcurrency User provided concurrency limit or undefined. + * @returns Valid concurrency limit integer. + */ function resolveConcurrencyLimit(maxConcurrency: number | undefined): number { if (maxConcurrency === undefined) return DEFAULT_CONCURRENCY; if (typeof maxConcurrency !== 'number') return DEFAULT_CONCURRENCY; @@ -36,6 +54,11 @@ function resolveConcurrencyLimit(maxConcurrency: number | undefined): number { return Math.min(maxConcurrency, MAX_ALLOWED_CONCURRENCY); } +/** + * Attaches the currently configured default AI provider metadata to the analysis output. + * Swallows exceptions to remain fail-safe in non-configured environments. + * @param output The analysis output object. + */ function tryAttachProvider(output: AnalysisOutput): void { try { const aiConfig = getAIConfig(); @@ -47,6 +70,12 @@ function tryAttachProvider(output: AnalysisOutput): void { } } +/** + * Executes a full Kubernetes analysis run across selected analyzers in parallel, + * respecting concurrency limits and monitoring cancellation signals. + * @param options Options configuration directing namespace, filters, context, and stats. + * @returns Aggregated analysis results containing status, problems, and stats. + */ export async function runAnalysis(options: AnalysisOptions): Promise { const errors: string[] = []; const results: AnalyzerResult[] = []; @@ -62,11 +91,13 @@ export async function runAnalysis(options: AnalysisOptions): Promise { while (index < analyzersToRun.length) { + if (options.signal?.aborted) break; const currentIndex = index++; const analyzer = analyzersToRun[currentIndex]; diff --git a/src/analysis/types.ts b/src/analysis/types.ts index 4b487e8..62193ac 100644 --- a/src/analysis/types.ts +++ b/src/analysis/types.ts @@ -10,6 +10,7 @@ export interface AnalysisOptions { maxConcurrency?: number; withStats?: boolean; withDocs?: boolean; + signal?: AbortSignal; } export interface AnalysisStats { diff --git a/src/analyzers/deployment.ts b/src/analyzers/deployment.ts index 53bb4f3..1a6e453 100644 --- a/src/analyzers/deployment.ts +++ b/src/analyzers/deployment.ts @@ -2,20 +2,34 @@ import type * as k8s from '@kubernetes/client-node'; import type { Analyzer, AnalyzerContext, AnalyzerResult, Failure } from './types'; import { listDeployments } from '../kubernetes/resources'; +/** + * Resolves the name of the Deployment, defaulting to 'unknown-deployment' if missing. + * @param deployment The Deployment object. + */ const deploymentName = (deployment: k8s.V1Deployment) => deployment.metadata?.name ?? 'unknown-deployment'; + +/** + * Resolves the namespace of the Deployment, defaulting to 'default' if missing. + * @param deployment The Deployment object. + */ const deploymentNamespace = (deployment: k8s.V1Deployment) => deployment.metadata?.namespace ?? 'default'; +/** + * Evaluates the Deployment specs and status, checks for replica availability and rollout progress delays. + * @param deployment The Deployment object. + * @returns Array of failures found. + */ const getDeploymentFailures = (deployment: k8s.V1Deployment): Failure[] => { const failures: Failure[] = []; const desired = deployment.spec?.replicas ?? 1; const available = deployment.status?.availableReplicas ?? 0; - const unavailable = deployment.status?.unavailableReplicas ?? Math.max(desired - available, 0); + const unavailable = deployment.status?.unavailableReplicas ?? 0; if (desired > available) { - failures.push({ text: `Deployment has ${available}/${desired} available replicas` }); - } - - if (unavailable > 0) { + failures.push({ + text: `Deployment has ${available}/${desired} available replica${desired === 1 ? '' : 's'}`, + }); + } else if (unavailable > 0) { failures.push({ text: `Deployment has ${unavailable} unavailable replica${unavailable === 1 ? '' : 's'}` }); } @@ -33,6 +47,9 @@ const getDeploymentFailures = (deployment: k8s.V1Deployment): Failure[] => { return failures; }; +/** + * Analyzer implementation focused on Kubernetes Deployments. + */ export const DeploymentAnalyzer: Analyzer = { name: 'Deployment', async analyze(context: AnalyzerContext): Promise { @@ -49,4 +66,3 @@ export const DeploymentAnalyzer: Analyzer = { }); }, }; - diff --git a/src/analyzers/node.ts b/src/analyzers/node.ts index 8018d40..1de738d 100644 --- a/src/analyzers/node.ts +++ b/src/analyzers/node.ts @@ -4,18 +4,34 @@ import { listNodes } from '../kubernetes/resources'; const PRESSURE_CONDITIONS = new Set(['MemoryPressure', 'DiskPressure', 'PIDPressure', 'NetworkUnavailable']); +/** + * Resolves the name of the Node, defaulting to 'unknown-node' if missing. + * @param node The Node object. + */ const nodeName = (node: k8s.V1Node) => node.metadata?.name ?? 'unknown-node'; -const getNodeFailures = (node: k8s.V1Node): Failure[] => { - const failures: Failure[] = []; +/** + * Checks if the Node's Ready condition is True, reporting a failure if not. + * @param node The Node object. + * @returns Failure detail or null. + */ +const checkNodeReady = (node: k8s.V1Node): Failure | null => { const ready = node.status?.conditions?.find((condition) => condition.type === 'Ready'); - if (ready?.status !== 'True') { - failures.push({ + return { text: `Node is not Ready${ready?.reason ? `: ${ready.reason}` : ''}${ready?.message ? ` - ${ready.message}` : ''}`, - }); + }; } + return null; +}; +/** + * Gathers failures based on Node conditions indicating high resource pressure or offline network. + * @param node The Node object. + * @returns Array of pressure failures. + */ +const getPressureFailures = (node: k8s.V1Node): Failure[] => { + const failures: Failure[] = []; for (const condition of node.status?.conditions ?? []) { if (PRESSURE_CONDITIONS.has(condition.type) && condition.status === 'True') { failures.push({ @@ -23,14 +39,39 @@ const getNodeFailures = (node: k8s.V1Node): Failure[] => { }); } } + return failures; +}; +/** + * Checks if the Node is spec-marked as unschedulable. + * @param node The Node object. + * @returns Failure detail or null. + */ +const getUnschedulableFailure = (node: k8s.V1Node): Failure | null => { if (node.spec?.unschedulable) { - failures.push({ text: 'Node is marked unschedulable' }); + return { text: 'Node is marked unschedulable' }; } + return null; +}; +/** + * Aggregates all Node related validation failures: readiness, pressure, unschedulable. + * @param node The Node object. + * @returns Array of aggregated failures. + */ +const getNodeFailures = (node: k8s.V1Node): Failure[] => { + const failures: Failure[] = []; + const readyFailure = checkNodeReady(node); + if (readyFailure) failures.push(readyFailure); + failures.push(...getPressureFailures(node)); + const unschedulable = getUnschedulableFailure(node); + if (unschedulable) failures.push(unschedulable); return failures; }; +/** + * Analyzer implementation focused on Kubernetes Nodes. + */ export const NodeAnalyzer: Analyzer = { name: 'Node', async analyze(context: AnalyzerContext): Promise { @@ -46,4 +87,3 @@ export const NodeAnalyzer: Analyzer = { }); }, }; - diff --git a/src/analyzers/pod.ts b/src/analyzers/pod.ts index df3152f..086e976 100644 --- a/src/analyzers/pod.ts +++ b/src/analyzers/pod.ts @@ -10,26 +10,54 @@ const WAITING_FAILURE_REASONS = new Set([ 'CreateContainerConfigError', ]); +/** + * Resolves the name of the Pod resource, defaulting to 'unknown-pod' if missing. + * @param pod The Pod object. + */ const podName = (pod: k8s.V1Pod) => pod.metadata?.name ?? 'unknown-pod'; -const podNamespace = (pod: k8s.V1Pod) => pod.metadata?.namespace ?? 'default'; -const getPodFailures = (pod: k8s.V1Pod): Failure[] => { - const failures: Failure[] = []; - const phase = pod.status?.phase; +/** + * Resolves the namespace of the Pod resource, defaulting to 'default' if missing. + * @param pod The Pod object. + */ +const podNamespace = (pod: k8s.V1Pod) => pod.metadata?.namespace ?? 'default'; - if (phase === 'Failed') { - failures.push({ text: `Pod phase is Failed${pod.status?.reason ? `: ${pod.status.reason}` : ''}` }); +/** + * Validates the Pod's overall phase, flagging it as a failure if phase is Failed. + * @param pod The Pod object to validate. + * @returns Array of failures found. + */ +const checkPodPhase = (pod: k8s.V1Pod): Failure[] => { + if (pod.status?.phase === 'Failed') { + return [{ text: `Pod phase is Failed${pod.status?.reason ? `: ${pod.status.reason}` : ''}` }]; } + return []; +}; - if (phase === 'Pending') { - const scheduled = pod.status?.conditions?.find((condition) => condition.type === 'PodScheduled'); - if (scheduled?.status === 'False') { - failures.push({ - text: `Pod is pending and unschedulable${scheduled.reason ? `: ${scheduled.reason}` : ''}${scheduled.message ? ` - ${scheduled.message}` : ''}`, - }); - } +/** + * Checks for scheduling bottlenecks in Pending pods by inspecting the PodScheduled condition. + * @param pod The Pod object. + * @returns Failures relating to scheduling issues. + */ +const checkPodScheduling = (pod: k8s.V1Pod): Failure[] => { + if (pod.status?.phase !== 'Pending') return []; + const scheduled = pod.status?.conditions?.find((condition) => condition.type === 'PodScheduled'); + if (scheduled?.status === 'False') { + return [{ + text: `Pod is pending and unschedulable${scheduled.reason ? `: ${scheduled.reason}` : ''}${scheduled.message ? ` - ${scheduled.message}` : ''}`, + }]; } + return []; +}; +/** + * Validates states of containers inside a pod, assessing waiting statuses, readiness, + * and high restart counts. + * @param pod The Pod object. + * @returns Array of container validation failures. + */ +const checkContainerStates = (pod: k8s.V1Pod): Failure[] => { + const failures: Failure[] = []; for (const status of pod.status?.containerStatuses ?? []) { const waiting = status.state?.waiting; if (waiting?.reason && WAITING_FAILURE_REASONS.has(waiting.reason)) { @@ -46,10 +74,23 @@ const getPodFailures = (pod: k8s.V1Pod): Failure[] => { failures.push({ text: `Container ${status.name} restarted ${status.restartCount} times` }); } } - return failures; }; +/** + * Aggregates all Pod related validation checks: phase, scheduling, and container states. + * @param pod The Pod object. + * @returns Array of aggregated failures. + */ +const getPodFailures = (pod: k8s.V1Pod): Failure[] => [ + ...checkPodPhase(pod), + ...checkPodScheduling(pod), + ...checkContainerStates(pod), +]; + +/** + * Analyzer implementation focused on Kubernetes Pods. + */ export const PodAnalyzer: Analyzer = { name: 'Pod', async analyze(context: AnalyzerContext): Promise { @@ -66,4 +107,3 @@ export const PodAnalyzer: Analyzer = { }); }, }; - diff --git a/src/analyzers/pvc.ts b/src/analyzers/pvc.ts index 0d6c666..043d1b7 100644 --- a/src/analyzers/pvc.ts +++ b/src/analyzers/pvc.ts @@ -2,25 +2,51 @@ import type * as k8s from '@kubernetes/client-node'; import type { Analyzer, AnalyzerContext, AnalyzerResult, Failure } from './types'; import { listPersistentVolumeClaims } from '../kubernetes/resources'; +/** + * Resolves the name of the PersistentVolumeClaim, defaulting to 'unknown-pvc' if missing. + * @param pvc The PersistentVolumeClaim object. + */ const pvcName = (pvc: k8s.V1PersistentVolumeClaim) => pvc.metadata?.name ?? 'unknown-pvc'; -const pvcNamespace = (pvc: k8s.V1PersistentVolumeClaim) => pvc.metadata?.namespace ?? 'default'; -const getPvcFailures = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => { - const failures: Failure[] = []; - const phase = pvc.status?.phase; +/** + * Resolves the namespace of the PersistentVolumeClaim, defaulting to 'default' if missing. + * @param pvc The PersistentVolumeClaim object. + */ +const pvcNamespace = (pvc: k8s.V1PersistentVolumeClaim) => pvc.metadata?.namespace ?? 'default'; - if (phase === 'Pending') { - if (!pvc.spec?.storageClassName) { - failures.push({ text: 'PersistentVolumeClaim is pending without a storage class' }); - } else { - failures.push({ text: 'PersistentVolumeClaim is Pending' }); - } +/** + * Validates issues related to a PVC in a Pending phase. + * Checks for a missing storage class. + * @param pvc The PersistentVolumeClaim object. + * @returns Array of failures found. + */ +const checkPendingPhase = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => { + if (pvc.status?.phase !== 'Pending') return []; + if (!pvc.spec?.storageClassName) { + return [{ text: 'PersistentVolumeClaim is pending without a storage class' }]; } + return [{ text: 'PersistentVolumeClaim is Pending' }]; +}; - if (phase === 'Lost') { - failures.push({ text: 'PersistentVolumeClaim is Lost' }); +/** + * Validates issues related to a PVC in a Lost phase. + * @param pvc The PersistentVolumeClaim object. + * @returns Array of failures found. + */ +const checkLostPhase = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => { + if (pvc.status?.phase === 'Lost') { + return [{ text: 'PersistentVolumeClaim is Lost' }]; } + return []; +}; +/** + * Collects failure details from status conditions of the PVC. + * @param pvc The PersistentVolumeClaim object. + * @returns Array of failures found. + */ +const collectStatusConditions = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => { + const failures: Failure[] = []; for (const condition of pvc.status?.conditions ?? []) { if (condition.message) { failures.push({ @@ -28,10 +54,23 @@ const getPvcFailures = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => { }); } } - return failures; }; +/** + * Aggregates all PVC validation checks: pending, lost, and status conditions. + * @param pvc The PersistentVolumeClaim object. + * @returns Array of aggregated failures. + */ +const getPvcFailures = (pvc: k8s.V1PersistentVolumeClaim): Failure[] => [ + ...checkPendingPhase(pvc), + ...checkLostPhase(pvc), + ...collectStatusConditions(pvc), +]; + +/** + * Analyzer implementation focused on Kubernetes PersistentVolumeClaims. + */ export const PersistentVolumeClaimAnalyzer: Analyzer = { name: 'PersistentVolumeClaim', async analyze(context: AnalyzerContext): Promise { @@ -48,4 +87,3 @@ export const PersistentVolumeClaimAnalyzer: Analyzer = { }); }, }; - diff --git a/src/analyzers/service.ts b/src/analyzers/service.ts index 29c9810..c2e1800 100644 --- a/src/analyzers/service.ts +++ b/src/analyzers/service.ts @@ -2,71 +2,94 @@ import type * as k8s from '@kubernetes/client-node'; import type { Analyzer, AnalyzerContext, AnalyzerResult, Failure } from './types'; import { labelsToSelector, listPods, listServices, readEndpoints } from '../kubernetes/resources'; +/** + * Resolves the name of the Service, defaulting to 'unknown-service' if missing. + * @param service The Service object. + */ const serviceName = (service: k8s.V1Service) => service.metadata?.name ?? 'unknown-service'; + +/** + * Resolves the namespace of the Service, defaulting to 'default' if missing. + * @param service The Service object. + */ const serviceNamespace = (service: k8s.V1Service) => service.metadata?.namespace ?? 'default'; +/** + * Helper to determine if an Endpoints resource contains any ready target addresses. + * @param endpoints The Endpoints object or undefined. + * @returns True if empty or no addresses are present, False otherwise. + */ const endpointsAreEmpty = (endpoints?: k8s.V1Endpoints) => !endpoints?.subsets?.some((subset) => (subset.addresses?.length ?? 0) > 0); -const getServiceFailures = async ( - service: k8s.V1Service, - context: AnalyzerContext, -): Promise => { - const failures: Failure[] = []; - const namespace = serviceNamespace(service); - const name = serviceName(service); - - if (service.spec?.type === 'ExternalName') { - return failures; - } - - const selector = labelsToSelector(service.spec?.selector); - let matchingPods: k8s.V1Pod[] = []; - if (selector) { - matchingPods = await listPods({ ...context, namespace, labelSelector: selector }); - if (!matchingPods.length) { - failures.push({ text: `Service selector matches no pods (${selector})` }); - } +/** + * Checks if a Service's selector matched any pods during listing. + * @param matchingPods Pod list for the service selector. + * @param selector The service selector filter string. + * @returns Array of failures found. + */ +const checkSelectorMatch = (matchingPods: k8s.V1Pod[], selector: string): Failure[] => { + if (!matchingPods.length) { + return [{ text: `Service selector matches no pods (${selector})` }]; } + return []; +}; - const endpoints = await readEndpoints(name, namespace, context); +/** + * Validates endpoints existence and readiness for the service. + * @param endpoints Service endpoints. + * @returns Array of failures found. + */ +const checkEndpoints = (endpoints: k8s.V1Endpoints | undefined): Failure[] => { if (endpointsAreEmpty(endpoints)) { - failures.push({ text: 'Service has no ready endpoints' }); + return [{ text: 'Service has no ready endpoints' }]; } + return []; +}; - if (selector && matchingPods.length > 0) { - const ports = service.spec?.ports ?? []; - for (const port of ports) { - const targetPort = port.targetPort ?? port.port; - let resolved = false; +/** + * Checks if all ports declared by a Service can map to exposed ports of matching backend Pods. + * Checks string port names and numeric port numbers. + * @param service The Service object. + * @param matchingPods Pods that match the service's selector. + * @returns Array of targetPort validation failures. + */ +const checkTargetPorts = ( + service: k8s.V1Service, + matchingPods: k8s.V1Pod[], +): Failure[] => { + const failures: Failure[] = []; + const ports = service.spec?.ports ?? []; - if (typeof targetPort === 'string') { - resolved = matchingPods.some((pod) => + for (const port of ports) { + const targetPort = port.targetPort ?? port.port; + + if (typeof targetPort === 'string') { + const resolved = matchingPods.some((pod) => + pod.spec?.containers?.some((container) => + container.ports?.some((cp) => cp.name === targetPort), + ), + ); + if (!resolved) { + failures.push({ + text: `Service target port '${targetPort}' appears unresolved (no matching container port name found in pods)`, + }); + } + } else if (typeof targetPort === 'number') { + const hasDeclaredPorts = matchingPods.some((pod) => + pod.spec?.containers?.some((container) => (container.ports?.length ?? 0) > 0), + ); + if (hasDeclaredPorts) { + const resolved = matchingPods.some((pod) => pod.spec?.containers?.some((container) => - container.ports?.some((cp) => cp.name === targetPort), + container.ports?.some((cp) => cp.containerPort === targetPort), ), ); if (!resolved) { failures.push({ - text: `Service target port '${targetPort}' appears unresolved (no matching container port name found in pods)`, + text: `Service target port ${targetPort} appears unresolved (no matching containerPort found in pods)`, }); } - } else if (typeof targetPort === 'number') { - const hasDeclaredPorts = matchingPods.some((pod) => - pod.spec?.containers?.some((container) => (container.ports?.length ?? 0) > 0), - ); - if (hasDeclaredPorts) { - resolved = matchingPods.some((pod) => - pod.spec?.containers?.some((container) => - container.ports?.some((cp) => cp.containerPort === targetPort), - ), - ); - if (!resolved) { - failures.push({ - text: `Service target port ${targetPort} appears unresolved (no matching containerPort found in pods)`, - }); - } - } } } } @@ -74,25 +97,96 @@ const getServiceFailures = async ( return failures; }; +/** + * Main validation routine evaluating selector matching, endpoints state, and target port maps for a Service. + * @param service The Service object. + * @param context Analyzer context mapping namespace and settings. + * @param podsInNamespace Pre-cached list of Pods in the service namespace. + * @returns Array of failures found. + */ +const getServiceFailures = async ( + service: k8s.V1Service, + context: AnalyzerContext, + podsInNamespace: k8s.V1Pod[], +): Promise => { + if (service.spec?.type === 'ExternalName') return []; + + const namespace = serviceNamespace(service); + const name = serviceName(service); + const failures: Failure[] = []; + + const selector = service.spec?.selector; + let matchingPods: k8s.V1Pod[] = []; + if (selector && Object.keys(selector).length > 0) { + const selectorStr = labelsToSelector(selector); + const selectorEntries = Object.entries(selector); + matchingPods = podsInNamespace.filter((pod) => { + const labels = pod.metadata?.labels ?? {}; + return selectorEntries.every(([key, val]) => labels[key] === val); + }); + failures.push(...checkSelectorMatch(matchingPods, selectorStr)); + } + + const endpoints = await readEndpoints(name, namespace, context); + failures.push(...checkEndpoints(endpoints)); + + if (selector && Object.keys(selector).length > 0 && matchingPods.length > 0) { + failures.push(...checkTargetPorts(service, matchingPods)); + } + + return failures; +}; + +/** + * Analyzer implementation focused on Kubernetes Services. + */ export const ServiceAnalyzer: Analyzer = { name: 'Service', async analyze(context: AnalyzerContext): Promise { const services = await listServices(context); - const results: AnalyzerResult[] = []; + const allPods = await listPods({ + kubeconfig: context.kubeconfig, + kubecontext: context.kubecontext, + namespace: context.namespace, + signal: context.signal, + }); - for (const service of services) { - const errors = await getServiceFailures(service, context); - if (errors.length) { - results.push({ - kind: 'Service', + const podsByNamespace = new Map(); + for (const pod of allPods) { + const ns = pod.metadata?.namespace ?? 'default'; + if (!podsByNamespace.has(ns)) { + podsByNamespace.set(ns, []); + } + podsByNamespace.get(ns)!.push(pod); + } + + const settled = await Promise.allSettled( + services.map(async (service) => { + const ns = serviceNamespace(service); + const podsInNamespace = podsByNamespace.get(ns) ?? []; + const errors = await getServiceFailures(service, context, podsInNamespace); + if (!errors.length) return null; + return { + kind: 'Service' as const, name: serviceName(service), - namespace: serviceNamespace(service), + namespace: ns, errors, + }; + }), + ); + + const results: AnalyzerResult[] = []; + for (const result of settled) { + if (result.status === 'fulfilled' && result.value !== null) { + results.push(result.value); + } else if (result.status === 'rejected') { + results.push({ + kind: 'Service', + name: 'unknown-service', + errors: [{ text: `Service analysis failed: ${result.reason?.message || String(result.reason)}` }], }); } } - return results; }, }; - diff --git a/src/analyzers/types.ts b/src/analyzers/types.ts index f95225a..4e5825c 100644 --- a/src/analyzers/types.ts +++ b/src/analyzers/types.ts @@ -9,6 +9,7 @@ export interface AnalyzerContext { kubeconfig?: string; kubecontext?: string; withDocs?: boolean; + signal?: AbortSignal; } export interface AnalyzerResult { diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 6dd25df..11f2abd 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -4,14 +4,31 @@ import { runAnalysis } from '../analysis/analysis'; import { formatJsonOutput, formatTextOutput } from '../analysis/output'; import type { AnalysisOptions } from '../analysis/types'; import { createSpinner } from '../ui/spinner'; +import { logger } from '../utils/logger'; +/** + * Helper to collect multiple filter flags from the CLI options into an array. + * @param value The newly passed filter option. + * @param previous Accumulator list of previously collected filters. + * @returns Array containing all collected filters. + */ const collectFilter = (value: string, previous: string[]) => [...previous, value]; +/** + * Validates and normalizes the output format choice. + * @param output The user-selected output format string. + * @returns The validated output format 'json' or 'text'. + * @throws An Error if the format is invalid. + */ const parseOutput = (output: string): AnalysisOptions['output'] => { if (output === 'json' || output === 'text') return output; throw new Error('Output format must be either "text" or "json"'); }; +/** + * Registers the `analyze` command and its options on the Commander program. + * @param program Commander program instance. + */ export const registerAnalyzeCommand = (program: Command) => { program .command('analyze') @@ -30,6 +47,14 @@ export const registerAnalyzeCommand = (program: Command) => { let output: AnalysisOptions['output'] = 'text'; let spinner: ReturnType | null = null; + const abortController = new AbortController(); + const onSigint = () => { + abortController.abort(); + spinner?.fail('Analysis cancelled'); + process.exitCode = 130; + }; + process.on('SIGINT', onSigint); + try { output = parseOutput(options.output); spinner = output === 'json' ? null : createSpinner('Analyzing Kubernetes resources...').start(); @@ -43,6 +68,7 @@ export const registerAnalyzeCommand = (program: Command) => { withDocs: Boolean(options.withDoc), kubeconfig: options.kubeconfig, kubecontext: options.kubecontext, + signal: abortController.signal, }); spinner?.stop('Analysis complete'); @@ -50,11 +76,13 @@ export const registerAnalyzeCommand = (program: Command) => { } catch (error) { spinner?.fail(`Analysis failed: ${(error as Error).message}`); if (output === 'json') { - console.error(JSON.stringify({ error: (error as Error).message }, null, 2)); + logger.error(JSON.stringify({ error: (error as Error).message }, null, 2)); } else { - console.error(chalk.red(`Analysis failed: ${(error as Error).message}`)); + logger.error(chalk.red(`Analysis failed: ${(error as Error).message}`)); } process.exitCode = 1; + } finally { + process.removeListener('SIGINT', onSigint); } }); }; diff --git a/src/kubernetes/client.ts b/src/kubernetes/client.ts index 82ed820..9838088 100644 --- a/src/kubernetes/client.ts +++ b/src/kubernetes/client.ts @@ -1,4 +1,7 @@ import * as k8s from '@kubernetes/client-node'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { logger } from '../utils/logger'; let kc: k8s.KubeConfig | null = null; let k8sApi: k8s.CoreV1Api | null = null; @@ -13,12 +16,31 @@ export interface KubernetesClientOptions { const getClientKey = (options: KubernetesClientOptions = {}) => `${options.kubeconfig ?? 'default'}::${options.kubecontext ?? 'current'}`; +/** + * Asserts that a specified kubeconfig path points to an actual existing file on the disk. + * @param filePath The resolved file path. + * @throws Error if path is not a file or does not exist. + */ +const validateKubeconfigPath = (filePath: string): void => { + const resolved = path.resolve(filePath); + const stat = fs.statSync(resolved); + if (!stat.isFile()) { + throw new Error(`Kubeconfig path is not a file: ${resolved}`); + } +}; + +/** + * Loads, configures, and caches a KubeConfig instance based on the provided overrides. + * @param options Options containing custom config file paths or context names. + * @returns Cached KubeConfig instance. + */ export const getKubeConfig = (options: KubernetesClientOptions = {}): k8s.KubeConfig => { const nextKey = getClientKey(options); if (!kc || clientKey !== nextKey) { kc = new k8s.KubeConfig(); try { if (options.kubeconfig) { + validateKubeconfigPath(options.kubeconfig); kc.loadFromFile(options.kubeconfig); } else { kc.loadFromDefault(); @@ -36,6 +58,11 @@ export const getKubeConfig = (options: KubernetesClientOptions = {}): k8s.KubeCo return kc; }; +/** + * Resolves a configured instance of the CoreV1Api client. + * @param options Options configuration. + * @returns CoreV1Api client. + */ export const getK8sApi = (options: KubernetesClientOptions = {}): k8s.CoreV1Api => { if (!k8sApi) { const config = getKubeConfig(options); @@ -44,6 +71,11 @@ export const getK8sApi = (options: KubernetesClientOptions = {}): k8s.CoreV1Api return k8sApi; }; +/** + * Resolves a configured instance of the AppsV1Api client. + * @param options Options configuration. + * @returns AppsV1Api client. + */ export const getAppsApi = (options: KubernetesClientOptions = {}): k8s.AppsV1Api => { if (!appsApi) { const config = getKubeConfig(options); diff --git a/src/kubernetes/resources.ts b/src/kubernetes/resources.ts index 63434ef..052df91 100644 --- a/src/kubernetes/resources.ts +++ b/src/kubernetes/resources.ts @@ -9,13 +9,41 @@ export interface KubernetesResourceOptions extends KubernetesClientOptions { type K8sList = { items?: T[] }; type K8sResponse = T | { body: T }; +/** + * Unwraps the K8s API response body if it's wrapped in a response container object. + * @param response K8s response container. + * @returns Unwrapped payload object. + */ const unwrap = (response: K8sResponse): T => response && typeof response === 'object' && 'body' in response ? (response as { body: T }).body : (response as T); +/** + * Extracts items list from a K8s response representing a collection of objects. + * @param response K8s list response container. + * @returns Array of resources. + */ const items = (response: K8sResponse>): T[] => unwrap(response).items ?? []; +/** + * Checks if the caught error matches a 404 NotFound HTTP status code. + * @param error Caught exception object. + * @returns True if code is 404, False otherwise. + */ +const isNotFoundError = (error: unknown): boolean => { + if (error && typeof error === 'object') { + const statusCode = (error as any).statusCode ?? (error as any).response?.statusCode ?? (error as any).code; + return statusCode === 404; + } + return false; +}; + +/** + * Queries the cluster to retrieve a list of Pods matching filters and namespace. + * @param options Target namespace, client configs, and selectors. + * @returns List of Pod resources. + */ export const listPods = async (options: KubernetesResourceOptions = {}): Promise => { const api = getK8sApi(options); const response = options.namespace @@ -24,6 +52,11 @@ export const listPods = async (options: KubernetesResourceOptions = {}): Promise return items(response); }; +/** + * Queries the cluster to retrieve a list of Deployments matching filters and namespace. + * @param options Target namespace, client configs, and selectors. + * @returns List of Deployment resources. + */ export const listDeployments = async ( options: KubernetesResourceOptions = {}, ): Promise => { @@ -34,6 +67,11 @@ export const listDeployments = async ( return items(response); }; +/** + * Queries the cluster to retrieve a list of Services matching filters and namespace. + * @param options Target namespace, client configs, and selectors. + * @returns List of Service resources. + */ export const listServices = async ( options: KubernetesResourceOptions = {}, ): Promise => { @@ -44,6 +82,11 @@ export const listServices = async ( return items(response); }; +/** + * Queries the cluster to retrieve a list of PersistentVolumeClaims matching filters and namespace. + * @param options Target namespace, client configs, and selectors. + * @returns List of PVC resources. + */ export const listPersistentVolumeClaims = async ( options: KubernetesResourceOptions = {}, ): Promise => { @@ -61,12 +104,25 @@ export const listPersistentVolumeClaims = async ( return items(response); }; +/** + * Queries the cluster to retrieve a list of Nodes matching filters. + * @param options Client configs and selectors. + * @returns List of Node resources. + */ export const listNodes = async (options: KubernetesResourceOptions = {}): Promise => { const api = getK8sApi(options); const response = await api.listNode(undefined, undefined, undefined, options.labelSelector); return items(response); }; +/** + * Reads detailed Endpoint mapping configurations for a specific Service. + * Suppresses NotFound (404) errors by returning undefined. + * @param name Service name. + * @param namespace Namespace name. + * @param options Client config settings. + * @returns Endpoints detail or undefined. + */ export const readEndpoints = async ( name: string, namespace: string, @@ -75,11 +131,19 @@ export const readEndpoints = async ( const api = getK8sApi(options); try { return unwrap(await api.readNamespacedEndpoints(name, namespace)); - } catch { - return undefined; + } catch (error) { + if (isNotFoundError(error)) { + return undefined; + } + throw error; } }; +/** + * Converts a simple key-value label map into a standard labelSelector string. + * @param labels Key-value map. + * @returns Formatted labelSelector selector. + */ export const labelsToSelector = (labels: Record = {}) => Object.entries(labels) .map(([key, value]) => `${key}=${value}`) From 693160e13d697b6ccfb3fd263fd8f4a5dd25f224 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 7 Jun 2026 13:27:29 +0530 Subject: [PATCH 5/7] refactor: optimize and simplify code health complexity for CodeScene validation --- src/analyzers/pod.ts | 43 +++++---- src/analyzers/service.ts | 189 +++++++++++++++++++++++++-------------- src/commands/analyze.ts | 94 ++++++++++--------- 3 files changed, 201 insertions(+), 125 deletions(-) diff --git a/src/analyzers/pod.ts b/src/analyzers/pod.ts index 086e976..6a29b99 100644 --- a/src/analyzers/pod.ts +++ b/src/analyzers/pod.ts @@ -51,32 +51,39 @@ const checkPodScheduling = (pod: k8s.V1Pod): Failure[] => { }; /** - * Validates states of containers inside a pod, assessing waiting statuses, readiness, - * and high restart counts. - * @param pod The Pod object. - * @returns Array of container validation failures. + * Validates the state of a single container status, checking for waiting failures, + * readiness, and restart count threshold breaches. + * @param status The container status object. + * @returns Array of failures found. */ -const checkContainerStates = (pod: k8s.V1Pod): Failure[] => { +const checkSingleContainerState = (status: k8s.V1ContainerStatus): Failure[] => { const failures: Failure[] = []; - for (const status of pod.status?.containerStatuses ?? []) { - const waiting = status.state?.waiting; - if (waiting?.reason && WAITING_FAILURE_REASONS.has(waiting.reason)) { - failures.push({ - text: `Container ${status.name} is waiting in ${waiting.reason}${waiting.message ? `: ${waiting.message}` : ''}`, - }); - } + const waiting = status.state?.waiting; + if (waiting?.reason && WAITING_FAILURE_REASONS.has(waiting.reason)) { + failures.push({ + text: `Container ${status.name} is waiting in ${waiting.reason}${waiting.message ? `: ${waiting.message}` : ''}`, + }); + } - if (!status.ready) { - failures.push({ text: `Container ${status.name} is not ready` }); - } + if (!status.ready) { + failures.push({ text: `Container ${status.name} is not ready` }); + } - if ((status.restartCount ?? 0) > RESTART_WARNING_THRESHOLD) { - failures.push({ text: `Container ${status.name} restarted ${status.restartCount} times` }); - } + if ((status.restartCount ?? 0) > RESTART_WARNING_THRESHOLD) { + failures.push({ text: `Container ${status.name} restarted ${status.restartCount} times` }); } return failures; }; +/** + * Validates states of containers inside a pod, assessing waiting statuses, readiness, + * and high restart counts. + * @param pod The Pod object. + * @returns Array of container validation failures. + */ +const checkContainerStates = (pod: k8s.V1Pod): Failure[] => + (pod.status?.containerStatuses ?? []).flatMap(checkSingleContainerState); + /** * Aggregates all Pod related validation checks: phase, scheduling, and container states. * @param pod The Pod object. diff --git a/src/analyzers/service.ts b/src/analyzers/service.ts index c2e1800..b35188b 100644 --- a/src/analyzers/service.ts +++ b/src/analyzers/service.ts @@ -47,6 +47,64 @@ const checkEndpoints = (endpoints: k8s.V1Endpoints | undefined): Failure[] => { return []; }; +/** + * Helper to determine if a string port is resolved in matching pods. + * @param targetPort The port name. + * @param pods The list of matching pods. + * @returns True if resolved, false otherwise. + */ +const isStringPortResolved = (targetPort: string, pods: k8s.V1Pod[]): boolean => + pods.some((pod) => + pod.spec?.containers?.some((container) => + container.ports?.some((cp) => cp.name === targetPort), + ), + ); + +/** + * Helper to determine if a numeric port is resolved in matching pods. + * @param targetPort The port number. + * @param pods The list of matching pods. + * @returns True if resolved, false otherwise. + */ +const isNumberPortResolved = (targetPort: number, pods: k8s.V1Pod[]): boolean => + pods.some((pod) => + pod.spec?.containers?.some((container) => + container.ports?.some((cp) => cp.containerPort === targetPort), + ), + ); + +/** + * Validates a single Service port mapping. + * @param port The Service port to validate. + * @param matchingPods Pods that match the service's selector. + * @returns Array of failures found. + */ +const checkSinglePort = ( + port: k8s.V1ServicePort, + matchingPods: k8s.V1Pod[], +): Failure[] => { + const targetPort = port.targetPort ?? port.port; + + if (typeof targetPort === 'string') { + if (!isStringPortResolved(targetPort, matchingPods)) { + return [{ + text: `Service target port '${targetPort}' appears unresolved (no matching container port name found in pods)`, + }]; + } + } else if (typeof targetPort === 'number') { + const hasDeclaredPorts = matchingPods.some((pod) => + pod.spec?.containers?.some((container) => (container.ports?.length ?? 0) > 0), + ); + if (hasDeclaredPorts && !isNumberPortResolved(targetPort, matchingPods)) { + return [{ + text: `Service target port ${targetPort} appears unresolved (no matching containerPort found in pods)`, + }]; + } + } + + return []; +}; + /** * Checks if all ports declared by a Service can map to exposed ports of matching backend Pods. * Checks string port names and numeric port numbers. @@ -58,43 +116,21 @@ const checkTargetPorts = ( service: k8s.V1Service, matchingPods: k8s.V1Pod[], ): Failure[] => { - const failures: Failure[] = []; - const ports = service.spec?.ports ?? []; - - for (const port of ports) { - const targetPort = port.targetPort ?? port.port; - - if (typeof targetPort === 'string') { - const resolved = matchingPods.some((pod) => - pod.spec?.containers?.some((container) => - container.ports?.some((cp) => cp.name === targetPort), - ), - ); - if (!resolved) { - failures.push({ - text: `Service target port '${targetPort}' appears unresolved (no matching container port name found in pods)`, - }); - } - } else if (typeof targetPort === 'number') { - const hasDeclaredPorts = matchingPods.some((pod) => - pod.spec?.containers?.some((container) => (container.ports?.length ?? 0) > 0), - ); - if (hasDeclaredPorts) { - const resolved = matchingPods.some((pod) => - pod.spec?.containers?.some((container) => - container.ports?.some((cp) => cp.containerPort === targetPort), - ), - ); - if (!resolved) { - failures.push({ - text: `Service target port ${targetPort} appears unresolved (no matching containerPort found in pods)`, - }); - } - } - } - } + return (service.spec?.ports ?? []).flatMap((port) => checkSinglePort(port, matchingPods)); +}; - return failures; +/** + * Filters a list of pods in-memory to find those matching a key-value selector. + * @param pods List of pods to filter. + * @param selector Key-value selector map. + * @returns Filtered pods matching the selector. + */ +const filterPodsBySelector = (pods: k8s.V1Pod[], selector: Record): k8s.V1Pod[] => { + const selectorEntries = Object.entries(selector); + return pods.filter((pod) => { + const labels = pod.metadata?.labels ?? {}; + return selectorEntries.every(([key, val]) => labels[key] === val); + }); }; /** @@ -116,27 +152,69 @@ const getServiceFailures = async ( const failures: Failure[] = []; const selector = service.spec?.selector; + const hasSelector = selector && Object.keys(selector).length > 0; let matchingPods: k8s.V1Pod[] = []; - if (selector && Object.keys(selector).length > 0) { - const selectorStr = labelsToSelector(selector); - const selectorEntries = Object.entries(selector); - matchingPods = podsInNamespace.filter((pod) => { - const labels = pod.metadata?.labels ?? {}; - return selectorEntries.every(([key, val]) => labels[key] === val); - }); + + if (hasSelector) { + const selectorStr = labelsToSelector(selector!); + matchingPods = filterPodsBySelector(podsInNamespace, selector!); failures.push(...checkSelectorMatch(matchingPods, selectorStr)); } const endpoints = await readEndpoints(name, namespace, context); failures.push(...checkEndpoints(endpoints)); - if (selector && Object.keys(selector).length > 0 && matchingPods.length > 0) { + if (hasSelector && matchingPods.length > 0) { failures.push(...checkTargetPorts(service, matchingPods)); } return failures; }; +/** + * Groups pods by their namespace for faster lookup. + * @param pods List of pods. + * @returns Map of namespace to pods list. + */ +const groupPodsByNamespace = (pods: k8s.V1Pod[]): Map => { + const podsByNamespace = new Map(); + for (const pod of pods) { + const ns = pod.metadata?.namespace ?? 'default'; + let list = podsByNamespace.get(ns); + if (!list) { + list = []; + podsByNamespace.set(ns, list); + } + list.push(pod); + } + return podsByNamespace; +}; + +/** + * Processes Promise.allSettled results to build AnalyzerResults. + * @param settled Settled analysis promises. + * @returns Aggregated AnalyzerResults list. + */ +const processSettledResults = ( + settled: PromiseSettledResult[], +): AnalyzerResult[] => { + const results: AnalyzerResult[] = []; + for (const result of settled) { + if (result.status === 'fulfilled') { + if (result.value !== null) { + results.push(result.value); + } + } else { + results.push({ + kind: 'Service', + name: 'unknown-service', + errors: [{ text: `Service analysis failed: ${result.reason?.message || String(result.reason)}` }], + }); + } + } + return results; +}; + /** * Analyzer implementation focused on Kubernetes Services. */ @@ -151,14 +229,7 @@ export const ServiceAnalyzer: Analyzer = { signal: context.signal, }); - const podsByNamespace = new Map(); - for (const pod of allPods) { - const ns = pod.metadata?.namespace ?? 'default'; - if (!podsByNamespace.has(ns)) { - podsByNamespace.set(ns, []); - } - podsByNamespace.get(ns)!.push(pod); - } + const podsByNamespace = groupPodsByNamespace(allPods); const settled = await Promise.allSettled( services.map(async (service) => { @@ -175,18 +246,6 @@ export const ServiceAnalyzer: Analyzer = { }), ); - const results: AnalyzerResult[] = []; - for (const result of settled) { - if (result.status === 'fulfilled' && result.value !== null) { - results.push(result.value); - } else if (result.status === 'rejected') { - results.push({ - kind: 'Service', - name: 'unknown-service', - errors: [{ text: `Service analysis failed: ${result.reason?.message || String(result.reason)}` }], - }); - } - } - return results; + return processSettledResults(settled); }, }; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 11f2abd..41826ee 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -25,6 +25,57 @@ const parseOutput = (output: string): AnalysisOptions['output'] => { throw new Error('Output format must be either "text" or "json"'); }; +/** + * Registers the `analyze` command and its options on the Commander program. + * @param program Commander program instance. + */ +/** + * Handler for the analyze CLI command execution. + * @param options CLI parsed options configuration. + */ +async function handleAnalyze(options: any): Promise { + let output: AnalysisOptions['output'] = 'text'; + let spinner: ReturnType | null = null; + + const abortController = new AbortController(); + const onSigint = () => { + abortController.abort(); + spinner?.fail('Analysis cancelled'); + process.exitCode = 130; + }; + process.on('SIGINT', onSigint); + + try { + output = parseOutput(options.output); + spinner = output === 'json' ? null : createSpinner('Analyzing Kubernetes resources...').start(); + const result = await runAnalysis({ + filters: options.filter.length ? options.filter : undefined, + namespace: options.namespace, + labelSelector: options.selector, + output, + maxConcurrency: Number.parseInt(options.maxConcurrency, 10), + withStats: Boolean(options.withStat), + withDocs: Boolean(options.withDoc), + kubeconfig: options.kubeconfig, + kubecontext: options.kubecontext, + signal: abortController.signal, + }); + + spinner?.stop('Analysis complete'); + console.log(output === 'json' ? formatJsonOutput(result) : formatTextOutput(result)); + } catch (error) { + spinner?.fail(`Analysis failed: ${(error as Error).message}`); + if (output === 'json') { + logger.error(JSON.stringify({ error: (error as Error).message }, null, 2)); + } else { + logger.error(chalk.red(`Analysis failed: ${(error as Error).message}`)); + } + process.exitCode = 1; + } finally { + process.removeListener('SIGINT', onSigint); + } +} + /** * Registers the `analyze` command and its options on the Commander program. * @param program Commander program instance. @@ -43,46 +94,5 @@ export const registerAnalyzeCommand = (program: Command) => { .option('--with-doc', 'Reserve Kubernetes documentation lookup for analyzer output') .option('--kubeconfig ', 'Path to kubeconfig file') .option('--kubecontext ', 'Kubernetes context to use') - .action(async (options) => { - let output: AnalysisOptions['output'] = 'text'; - let spinner: ReturnType | null = null; - - const abortController = new AbortController(); - const onSigint = () => { - abortController.abort(); - spinner?.fail('Analysis cancelled'); - process.exitCode = 130; - }; - process.on('SIGINT', onSigint); - - try { - output = parseOutput(options.output); - spinner = output === 'json' ? null : createSpinner('Analyzing Kubernetes resources...').start(); - const result = await runAnalysis({ - filters: options.filter.length ? options.filter : undefined, - namespace: options.namespace, - labelSelector: options.selector, - output, - maxConcurrency: Number.parseInt(options.maxConcurrency, 10), - withStats: Boolean(options.withStat), - withDocs: Boolean(options.withDoc), - kubeconfig: options.kubeconfig, - kubecontext: options.kubecontext, - signal: abortController.signal, - }); - - spinner?.stop('Analysis complete'); - console.log(output === 'json' ? formatJsonOutput(result) : formatTextOutput(result)); - } catch (error) { - spinner?.fail(`Analysis failed: ${(error as Error).message}`); - if (output === 'json') { - logger.error(JSON.stringify({ error: (error as Error).message }, null, 2)); - } else { - logger.error(chalk.red(`Analysis failed: ${(error as Error).message}`)); - } - process.exitCode = 1; - } finally { - process.removeListener('SIGINT', onSigint); - } - }); + .action(handleAnalyze); }; From 2c60b4bb5dbd0e08b433f5008e8ebacf70e5a56e Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 7 Jun 2026 13:29:30 +0530 Subject: [PATCH 6/7] refactor: optimize and simplify deployment, service, and analyze command for CodeScene delta gates --- src/analyzers/deployment.ts | 24 +++++++++-- src/analyzers/service.ts | 54 +++++++++++++++++++------ src/commands/analyze.ts | 81 +++++++++++++++++++++++++++---------- 3 files changed, 122 insertions(+), 37 deletions(-) diff --git a/src/analyzers/deployment.ts b/src/analyzers/deployment.ts index 1a6e453..7ff6ce9 100644 --- a/src/analyzers/deployment.ts +++ b/src/analyzers/deployment.ts @@ -15,11 +15,11 @@ const deploymentName = (deployment: k8s.V1Deployment) => deployment.metadata?.na const deploymentNamespace = (deployment: k8s.V1Deployment) => deployment.metadata?.namespace ?? 'default'; /** - * Evaluates the Deployment specs and status, checks for replica availability and rollout progress delays. + * Checks deployment replica status, comparing desired, available, and unavailable counts. * @param deployment The Deployment object. * @returns Array of failures found. */ -const getDeploymentFailures = (deployment: k8s.V1Deployment): Failure[] => { +const checkDeploymentReplicas = (deployment: k8s.V1Deployment): Failure[] => { const failures: Failure[] = []; const desired = deployment.spec?.replicas ?? 1; const available = deployment.status?.availableReplicas ?? 0; @@ -32,7 +32,16 @@ const getDeploymentFailures = (deployment: k8s.V1Deployment): Failure[] => { } else if (unavailable > 0) { failures.push({ text: `Deployment has ${unavailable} unavailable replica${unavailable === 1 ? '' : 's'}` }); } + return failures; +}; +/** + * Checks deployment status conditions, focusing on rollout deadlines and failed status flags. + * @param deployment The Deployment object. + * @returns Array of failures found. + */ +const checkDeploymentConditions = (deployment: k8s.V1Deployment): Failure[] => { + const failures: Failure[] = []; for (const condition of deployment.status?.conditions ?? []) { if (condition.type === 'Progressing' && condition.reason === 'ProgressDeadlineExceeded') { failures.push({ @@ -43,10 +52,19 @@ const getDeploymentFailures = (deployment: k8s.V1Deployment): Failure[] => { failures.push({ text: `Deployment condition ${condition.type} is False: ${condition.message}` }); } } - return failures; }; +/** + * Evaluates the Deployment specs and status, checks for replica availability and rollout progress delays. + * @param deployment The Deployment object. + * @returns Array of failures found. + */ +const getDeploymentFailures = (deployment: k8s.V1Deployment): Failure[] => [ + ...checkDeploymentReplicas(deployment), + ...checkDeploymentConditions(deployment), +]; + /** * Analyzer implementation focused on Kubernetes Deployments. */ diff --git a/src/analyzers/service.ts b/src/analyzers/service.ts index b35188b..38cee11 100644 --- a/src/analyzers/service.ts +++ b/src/analyzers/service.ts @@ -73,6 +73,45 @@ const isNumberPortResolved = (targetPort: number, pods: k8s.V1Pod[]): boolean => ), ); +/** + * Checks if a Service string-based targetPort resolves to a named container port in pods. + * @param targetPort The port name. + * @param matchingPods Pods that match the service's selector. + * @returns Array of failures found. + */ +const checkStringPort = ( + targetPort: string, + matchingPods: k8s.V1Pod[], +): Failure[] => { + if (!isStringPortResolved(targetPort, matchingPods)) { + return [{ + text: `Service target port '${targetPort}' appears unresolved (no matching container port name found in pods)`, + }]; + } + return []; +}; + +/** + * Checks if a Service numeric targetPort is declared and resolved in matching pods. + * @param targetPort The port number. + * @param matchingPods Pods that match the service's selector. + * @returns Array of failures found. + */ +const checkNumericPort = ( + targetPort: number, + matchingPods: k8s.V1Pod[], +): Failure[] => { + const hasDeclaredPorts = matchingPods.some((pod) => + pod.spec?.containers?.some((container) => (container.ports?.length ?? 0) > 0), + ); + if (hasDeclaredPorts && !isNumberPortResolved(targetPort, matchingPods)) { + return [{ + text: `Service target port ${targetPort} appears unresolved (no matching containerPort found in pods)`, + }]; + } + return []; +}; + /** * Validates a single Service port mapping. * @param port The Service port to validate. @@ -86,20 +125,9 @@ const checkSinglePort = ( const targetPort = port.targetPort ?? port.port; if (typeof targetPort === 'string') { - if (!isStringPortResolved(targetPort, matchingPods)) { - return [{ - text: `Service target port '${targetPort}' appears unresolved (no matching container port name found in pods)`, - }]; - } + return checkStringPort(targetPort, matchingPods); } else if (typeof targetPort === 'number') { - const hasDeclaredPorts = matchingPods.some((pod) => - pod.spec?.containers?.some((container) => (container.ports?.length ?? 0) > 0), - ); - if (hasDeclaredPorts && !isNumberPortResolved(targetPort, matchingPods)) { - return [{ - text: `Service target port ${targetPort} appears unresolved (no matching containerPort found in pods)`, - }]; - } + return checkNumericPort(targetPort, matchingPods); } return []; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 41826ee..add708c 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -29,6 +29,59 @@ const parseOutput = (output: string): AnalysisOptions['output'] => { * Registers the `analyze` command and its options on the Commander program. * @param program Commander program instance. */ +/** + * Builds the analysis configuration options from the CLI raw options. + * @param options CLI parsed options. + * @param signal AbortSignal for cancellation. + * @returns Configured AnalysisOptions. + */ +const buildAnalysisOptions = (options: any, signal: AbortSignal): AnalysisOptions => ({ + filters: options.filter?.length ? options.filter : undefined, + namespace: options.namespace, + labelSelector: options.selector, + output: parseOutput(options.output), + maxConcurrency: Number.parseInt(options.maxConcurrency, 10), + withStats: Boolean(options.withStat), + withDocs: Boolean(options.withDoc), + kubeconfig: options.kubeconfig, + kubecontext: options.kubecontext, + signal, +}); + +/** + * Formats and prints the analysis result to standard output. + * @param result The analysis result. + * @param output Output format ('json' | 'text'). + */ +const printAnalysisResult = (result: any, output: AnalysisOptions['output']): void => { + if (output === 'json') { + console.log(formatJsonOutput(result)); + } else { + console.log(formatTextOutput(result)); + } +}; + +/** + * Handles errors occurred during the analysis run, logging them and setting process exit code. + * @param error The thrown error. + * @param output Output format ('json' | 'text'). + * @param spinner The spinner instance to fail. + */ +const handleAnalysisError = ( + error: unknown, + output: AnalysisOptions['output'], + spinner: ReturnType | null, +): void => { + const errMsg = (error as Error).message || String(error); + spinner?.fail(`Analysis failed: ${errMsg}`); + if (output === 'json') { + logger.error(JSON.stringify({ error: errMsg }, null, 2)); + } else { + logger.error(chalk.red(`Analysis failed: ${errMsg}`)); + } + process.exitCode = 1; +}; + /** * Handler for the analyze CLI command execution. * @param options CLI parsed options configuration. @@ -47,30 +100,16 @@ async function handleAnalyze(options: any): Promise { try { output = parseOutput(options.output); - spinner = output === 'json' ? null : createSpinner('Analyzing Kubernetes resources...').start(); - const result = await runAnalysis({ - filters: options.filter.length ? options.filter : undefined, - namespace: options.namespace, - labelSelector: options.selector, - output, - maxConcurrency: Number.parseInt(options.maxConcurrency, 10), - withStats: Boolean(options.withStat), - withDocs: Boolean(options.withDoc), - kubeconfig: options.kubeconfig, - kubecontext: options.kubecontext, - signal: abortController.signal, - }); + if (output !== 'json') { + spinner = createSpinner('Analyzing Kubernetes resources...').start(); + } + const runOpts = buildAnalysisOptions(options, abortController.signal); + const result = await runAnalysis(runOpts); spinner?.stop('Analysis complete'); - console.log(output === 'json' ? formatJsonOutput(result) : formatTextOutput(result)); + printAnalysisResult(result, output); } catch (error) { - spinner?.fail(`Analysis failed: ${(error as Error).message}`); - if (output === 'json') { - logger.error(JSON.stringify({ error: (error as Error).message }, null, 2)); - } else { - logger.error(chalk.red(`Analysis failed: ${(error as Error).message}`)); - } - process.exitCode = 1; + handleAnalysisError(error, output, spinner); } finally { process.removeListener('SIGINT', onSigint); } From 95f637acfe6348e576504484acbdd20eef453773 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 7 Jun 2026 13:32:26 +0530 Subject: [PATCH 7/7] docs: add coding_style.md to guide future CodeScene gate compliance --- coding_style.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 coding_style.md diff --git a/coding_style.md b/coding_style.md new file mode 100644 index 0000000..339cabb --- /dev/null +++ b/coding_style.md @@ -0,0 +1,107 @@ +# Coding Style & CodeScene Compliance Guide + +This guide establishes the mandatory coding style rules for this repository to ensure that all future code modifications automatically pass CodeScene quality gates. Any AI agent or developer modifying the codebase MUST strictly follow these rules. + +--- + +## 🚀 Core Health Metrics Thresholds + +We enforce the **Bare Minimum** quality gate profile on PR delta analysis: + +| Code biomarker | Rule threshold | Target score | +| :--- | :--- | :--- | +| **Cyclomatic Complexity** | Strictly `< 9` per function | `10.00 / 10.00` | +| **Nesting Depth** | Strictly `< 4` levels of indentation | `10.00 / 10.00` | +| **Bumpy Road Ahead** | Max `1` nested conditional block per function | `10.00 / 10.00` | +| **Docstring Coverage** | Strictly `> 80%` of functions & modules documented | Green check | + +--- + +## 🛠️ Rules & Refactoring Patterns + +### 1. Cyclomatic Complexity Control +If a function has multiple branches (`if`, `else if`, `for`, `flatMap`, `switch`, or ternaries), its complexity climbs rapidly. +* **Rule**: Keep functions under **7 lines of active logic** whenever possible. +* **Pattern**: Extract complex branches into single-purpose helper functions. + ```typescript + // ❌ BAD: High complexity aggregation + const getFailures = (resource: any) => { + const failures = []; + if (resource.spec?.replicas > resource.status?.available) { + failures.push({ text: 'Replica mismatch' }); + } + for (const condition of resource.status?.conditions ?? []) { + if (condition.type === 'Ready' && condition.status === 'False') { + failures.push({ text: 'Not Ready' }); + } + } + return failures; + }; + + // class=line-numbers + // 🟢 GOOD: Split into simple testable helpers + const checkReplicas = (resource: any) => ...; + const checkConditions = (resource: any) => ...; + const getFailures = (resource: any) => [...checkReplicas(resource), ...checkConditions(resource)]; + ``` + +### 2. Nested Indentation & Bumpy Road Ahead +Nesting conditionals or loops inside other blocks creates deep paths that are hard to read and trigger "Deep, Nested Complexity" warnings. +* **Rule**: Indentation depth must never exceed **3 levels**. +* **Pattern**: Return early (guard clauses) and delegate loops or child elements. + ```typescript + // ❌ BAD: Indentation level = 4 + const validatePorts = (ports: Port[], pods: Pod[]) => { + for (const port of ports) { + if (typeof port.target === 'string') { + const resolved = pods.some(pod => pod.spec.containers.some(c => c.portName === port.target)); + if (!resolved) { ... } + } + } + }; + + // 🟢 GOOD: Indentation level <= 2 + const isPortResolved = (target: string, pods: Pod[]) => ...; + const validateSinglePort = (port: Port, pods: Pod[]) => { + if (typeof port.target !== 'string') return []; + if (!isPortResolved(port.target, pods)) { + return [{ text: 'Unresolved port' }]; + } + return []; + }; + ``` + +### 3. Complex Conditionals +Avoid checking multiple states in a single line. +* **Rule**: Split long compound boolean expressions. +* **Pattern**: Assign checks to descriptive variables or helper methods. + ```typescript + // ❌ BAD: Compound conditional with multiple branches + if (selector && Object.keys(selector).length > 0 && matchingPods.length > 0) { ... } + + // 🟢 GOOD: Extracted variables + const hasSelector = selector && Object.keys(selector).length > 0; + if (hasSelector && matchingPods.length > 0) { ... } + ``` + +### 4. Docstring Coverage (>80%) +CodeScene checks for standard JSDoc/TSDoc blocks on functions and modules. +* **Rule**: Every exported class, interface, command handler, helper method, and analyzer function must have a JSDoc block describing: + 1. A clear sentence of what it does. + 2. Description of every `@param`. + 3. Description of the `@returns` type. + +--- + +## 📈 Verification Checklist Before Pushing +Always execute the following checks before committing code: + +1. **Build Checklist**: Ensure type compiling is clean: + ```bash + npm run build + ``` +2. **Test Checklist**: Ensure zero test regressions: + ```bash + npm run test + ``` +3. **Complexity Checklist**: Do a mental dry-run of modified functions to check that cyclomatic complexity is `< 9`.