Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions src/__tests__/health.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Command } from 'commander';
import { registerHealthCommand } from '../commands/health';
import { getRunningContainers } from '../docker/containers';
import { getRunningPods } from '../kubernetes/pods';
import { logger } from '../utils/logger';
import * as tableUtils from '../ui/table';

vi.mock('../docker/containers', () => ({
getRunningContainers: vi.fn(),
}));
vi.mock('../kubernetes/pods', () => ({
getRunningPods: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../ui/spinner', () => ({
createSpinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
stop: vi.fn().mockReturnThis(),
})),
}));
vi.mock('../ui/table', () => ({
renderTable: vi.fn(),
}));

describe('health command', () => {
let program: Command;
Expand All @@ -23,8 +42,24 @@ describe('health command', () => {
expect(healthCmd).toBeDefined();
});

it('should call logger.info on health <target>', async () => {
it('should render health for all workloads', async () => {
(getRunningContainers as any).mockResolvedValue([{ name: 'web', state: 'running', status: 'Up' }]);
(getRunningPods as any).mockResolvedValue([{ name: 'api', namespace: 'default', status: 'Running', restarts: 0 }]);

await program.parseAsync(['node', 'test', 'health', 'all']);
expect(logger.info).toHaveBeenCalledWith('Showing health for all...');

expect(tableUtils.renderTable).toHaveBeenCalledWith(expect.objectContaining({
head: ['TYPE', 'NAME', 'HEALTH', 'DETAILS'],
rows: expect.arrayContaining([
expect.arrayContaining(['container', 'web']),
expect.arrayContaining(['pod', 'api']),
]),
}));
});

it('should reject unknown health targets', async () => {
await program.parseAsync(['node', 'test', 'health', 'bad-target']);

expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Unknown target'));
});
});
74 changes: 70 additions & 4 deletions src/commands/health.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,77 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { getRunningContainers } from '../docker/containers';
import { getRunningPods } from '../kubernetes/pods';
import { logger } from '../utils/logger';
import { createSpinner } from '../ui/spinner';
import { renderTable } from '../ui/table';

const healthColor = (status: string) => {
if (status === 'healthy' || status === 'running' || status === 'Running') {
return chalk.green(status);
}

if (status === 'unhealthy' || status === 'exited' || status === 'Failed') {
return chalk.red(status);
}

return chalk.yellow(status);
};

export const showHealth = async (target: string) => {
logger.info?.(`Showing health for ${target}...`);

if (target !== 'all' && target !== 'containers' && target !== 'pods') {
logger.error?.(`Unknown target: ${target}. Valid targets are: all, pods, containers.`);
Comment on lines +22 to +25
return;
}

const spinner = createSpinner(`Checking ${target} health...`).start();

try {
const rows: (string | number)[][] = [];

if (target === 'all' || target === 'containers') {
const containers = await getRunningContainers();
rows.push(...containers.map((container) => [
'container',
container.name,
healthColor(container.state),
container.status,
]));
}

if (target === 'all' || target === 'pods') {
const pods = await getRunningPods();
rows.push(...pods.map((pod) => [
'pod',
pod.name,
healthColor(pod.status),
`namespace: ${pod.namespace}, restarts: ${pod.restarts}`,
]));
}

spinner.stop();

if (rows.length === 0) {
logger.warn(`No ${target === 'all' ? 'workloads' : target} found.`);
return;
}

renderTable({
head: ['TYPE', 'NAME', 'HEALTH', 'DETAILS'],
rows,
});
} catch (error) {
spinner.stop();
const message = error instanceof Error ? error.message : String(error);
logger.error(`Failed to check ${target} health: ${message}`);
}
};

export const registerHealthCommand = (program: Command) => {
program
.command('health <target>')
.description('Show health status for pods or containers')
.action((target) => {
logger.info(`Showing health for ${target}...`);
});
.description('Show health status for pods, containers, or all workloads')
.action(showHealth);
};
59 changes: 56 additions & 3 deletions src/commands/logs.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,64 @@
import { Command } from 'commander';
import { getDockerClient } from '../docker/client';
import { getK8sApi } from '../kubernetes/client';
import { logger } from '../utils/logger';
import { createSpinner } from '../ui/spinner';

const printStream = (value: unknown) => {
if (Buffer.isBuffer(value)) {
process.stdout.write(value.toString());
return;
}

process.stdout.write(String(value));
};

export const showLogs = async (name: string) => {
logger.info?.(`Showing logs for ${name}...`);
const spinner = createSpinner(`Fetching logs for ${name}...`).start();

try {
const docker = getDockerClient();
const containers = await docker.listContainers({ all: true });
const match = containers.find((container) =>
container.Id.startsWith(name) ||
container.Names.some((containerName) => containerName.replace(/^\//, '') === name)
);

if (match) {
const output = await docker.getContainer(match.Id).logs({ stdout: true, stderr: true, tail: 100 });
spinner.stop();
printStream(output);
return;
}
} catch {
// Fall through to Kubernetes logs when Docker is unavailable or has no match.
Comment on lines +20 to +35
}
Comment on lines +34 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Silent catch swallows Docker errors without logging.

The empty catch block means Docker client initialization errors, network issues, or permission problems are silently ignored. Users won't know why Docker logs failed before falling back to Kubernetes.

🔍 Add minimal logging for Docker fallback
-  } catch {
-    // Fall through to Kubernetes logs when Docker is unavailable or has no match.
+  } catch (error) {
+    logger.debug?.(`Docker unavailable, trying Kubernetes: ${error instanceof Error ? error.message : String(error)}`);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch {
// Fall through to Kubernetes logs when Docker is unavailable or has no match.
}
} catch (error) {
logger.debug?.(`Docker unavailable, trying Kubernetes: ${error instanceof Error ? error.message : String(error)}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/logs.ts` around lines 34 - 36, The empty catch in
src/commands/logs.ts swallows Docker client errors; change the catch to capture
the error (e.g., catch (err)) and emit a minimal warning before falling back to
Kubernetes, e.g., use the existing logger (processLogger.warn or logger.warn) or
console.warn to log "Docker logs unavailable, falling back to Kubernetes:" plus
the error message/stack; keep the fallback behavior intact so only a brief
diagnostic is added.


try {
const api = getK8sApi();
const pods = await api.listPodForAllNamespaces();
const pod = pods.body.items.find((item) => item.metadata?.name === name);

if (!pod?.metadata?.name || !pod.metadata.namespace) {
spinner.stop();
logger.error?.(`No container or pod named ${name} found.`);
return;
}

const response = await api.readNamespacedPodLog(pod.metadata.name, pod.metadata.namespace, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 100);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the readNamespacedPodLog method signature in the Kubernetes client
rg -A 10 'readNamespacedPodLog' --type=ts -g '!node_modules' -g '!dist'

Repository: KDM-cli/kdm-cli

Length of output: 782


🏁 Script executed:

# Find the import statement for the api object
head -50 src/commands/logs.ts | grep -E "import.*api|from.*api"

Repository: KDM-cli/kdm-cli

Length of output: 41


🏁 Script executed:

# Check the full context of the logs.ts file to understand the api object
head -80 src/commands/logs.ts

Repository: KDM-cli/kdm-cli

Length of output: 2175


🏁 Script executed:

# Search for readNamespacedPodLog in the entire codebase including node_modules types
fd readNamespacedPodLog --type f 2>/dev/null || rg 'readNamespacedPodLog' -g '*.d.ts' -A 5

Repository: KDM-cli/kdm-cli

Length of output: 41


🏁 Script executed:

# Find the getK8sApi implementation
cat -n src/kubernetes/client.ts

Repository: KDM-cli/kdm-cli

Length of output: 1315


🏁 Script executed:

# Check if there's a package.json to see which Kubernetes client library is used
cat package.json | grep -A 5 -B 5 "@kubernetes"

Repository: KDM-cli/kdm-cli

Length of output: 308


🌐 Web query:

@kubernetes/client-node readNamespacedPodLog method signature parameters options object

💡 Result:

In @kubernetes/client-node (TypeScript/JavaScript), CoreV1Api.readNamespacedPodLog is generated and can be called in either a positional-arguments form or (more reliably) an options-object form. 1) Options-object form (recommended) Signature shape (from usage): readNamespacedPodLog({ name, namespace, container,...otherQueryParams }) [1] Example that works: await k8sApi.readNamespacedPodLog({ name: 'nginx', namespace: 'default', container: 'nginx' }) [1] 2) Positional-arguments form (what the generated client expects) People call it with multiple positional parameters (name, namespace, container, follow,...), e.g. the reported call looks like: readNamespacedPodLog('ben', 'default', 'node-red', true, false, undefined, "true", false, undefined, 50, true) [2] Because the repository’s auto-generated docs have been noted as occasionally out-of-date/broken, the most dependable way to get the exact parameter order in your installed version is to inspect the generated code or your IDE IntelliSense (the maintainers explicitly recommend this when docs don’t match) [1]. 3) “options” object parameter You’ll also see a trailing “options” object in other API methods; for readNamespacedPodLog specifically, the GitHub issue on follow=true explains that the generated client’s typing/docs around streaming/follow behavior may not match the underlying REST semantics, and points to custom log handling instead of relying on the generated method for streaming/tailing [2]. 4) Important behavioral note: follow=true doesn’t work as expected with the generated method When follow=true is used, readNamespacedPodLog can “hang” (never resolve its promise), because follow causes the server-side HTTP connection to stay open and the OpenAPI-generated client expects the call to terminate [2]. The follow=true behavior is therefore client-library-specific and may require custom handling (the maintainer points to a custom log implementation in the repo) [2]. If you tell me your exact @kubernetes/client-node version (and whether you want CoreV1Api vs another API), I can extract the exact readNamespacedPodLog TypeScript signature (parameter names and order) for that version from the generated coreV1Api.ts source in that release.

Citations:


🌐 Web query:

@kubernetes/client-node CoreV1Api readNamespacedPodLog API documentation

💡 Result:

@kubernetes/client-node CoreV1Api readNamespacedPodLog (JavaScript/TypeScript) documentation summary (as of today, 2026-05-14): 1) What it does / endpoint - The CoreV1Api method ReadNamespacedPodLog maps to the Kubernetes REST endpoint: GET /api/v1/namespaces/{namespace}/pods/{name}/log [1]. 2) How to call it in @kubernetes/client-node - You create the API client via makeApiClient(CoreV1Api) and then call readNamespacedPodLog [2]. - Example (from the @kubernetes/client-node python example for the analogous CoreV1Api method): v1.read_namespaced_pod_log(name=pod_name, namespace=namespace, container=container_name, tail_lines=5) [3]. - Example (JavaScript client usage; minimal parameters): k8sApi.readNamespacedPodLog(name, namespace) and the response body contains the logs [3]. 3) Parameters you typically care about (and why docs may be confusing) - The method signature includes required pod name and namespace (and an optional container), plus common log query options like follow, previous, sinceSeconds, tailLines, limitBytes, etc. The exact signature is auto-generated and should be treated as the source of truth for your installed client version. - There have been issues where the auto-generated docs/signatures were incorrect or misleading for readNamespacedPodLog (for example, required arguments not matching, or docs being out of date). In one report, the fix was to consult the generated code / correct signature, because the published docs were out of date [4]. 4) follow=true behavior (important edge case) - With follow enabled, log streaming behavior may cause the client promise to not resolve as expected (because the HTTP connection is kept open). This is discussed in the kubernetes-client/javascript issue tracker; follow=true “never closes the TCP stream” / the generated client only understands RESTful semantics that assume termination [5]. 5) Getting the most authoritative, up-to-date “documentation” for your installed version - @kubernetes/client-node maintainers point out that “the Kubernetes API Reference is the source-of-truth” for the generated clients and that TypeDoc-generated docs exist and can be built locally [2]. Practical guidance: - Prefer building/inspecting the TypeDoc or generated source for your exact installed @kubernetes/client-node version if you need the exact readNamespacedPodLog parameter order/types (since there have been historical doc/sig mismatches) [2][4]. - If you use follow=true, don’t await completion expecting a finite result; instead treat it as a stream and handle it accordingly [5].

Citations:


🏁 Script executed:

# Check if there are type definitions or examples in node_modules
find node_modules/@kubernetes -name "*.d.ts" -type f 2>/dev/null | head -5

Repository: KDM-cli/kdm-cli

Length of output: 41


🏁 Script executed:

# Look for examples or tests that show how readNamespacedPodLog is used
rg 'readNamespacedPodLog' -A 2 -B 2 --type=ts --type=js

Repository: KDM-cli/kdm-cli

Length of output: 412


Use the options object pattern for readNamespacedPodLog instead of positional arguments.

Passing 10 undefined values to reach the tailLines parameter is fragile and unmaintainable. The @kubernetes/client-node library supports an options-object form that is both clearer and more resistant to future breaking changes:

-    const response = await api.readNamespacedPodLog(pod.metadata.name, pod.metadata.namespace, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 100);
+    const response = await api.readNamespacedPodLog({
+      name: pod.metadata.name,
+      namespace: pod.metadata.namespace,
+      tailLines: 100
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/logs.ts` at line 49, Replace the fragile positional-argument
call to readNamespacedPodLog with the options-object form: instead of passing
many undefineds to reach tailLines, call readNamespacedPodLog using
pod.metadata.name and pod.metadata.namespace and pass an options object
containing tailLines: 100 (e.g., readNamespacedPodLog(pod.metadata.name,
pod.metadata.namespace, { tailLines: 100 })) so the call is clearer and
resilient to future signature changes.

spinner.stop();
printStream(response.body);
} catch (error) {
spinner.stop();
const message = error instanceof Error ? error.message : String(error);
logger.error?.(`Failed to fetch logs for ${name}: ${message}`);
}
};
Comment on lines +16 to +57

export const registerLogsCommand = (program: Command) => {
program
.command('logs <name>')
.description('Show logs for a container or pod')
.action((name) => {
logger.info(`Showing logs for ${name}...`);
});
.action(showLogs);
};
Loading