Skip to content

Commit 8d0ba93

Browse files
committed
feat: allow port forward to deployment and service
1 parent c52eabd commit 8d0ba93

8 files changed

Lines changed: 942 additions & 1 deletion

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as k8s from '@kubernetes/client-node';
2+
import net from 'node:net';
3+
4+
const kc = new k8s.KubeConfig();
5+
kc.loadFromDefault();
6+
7+
const forward = new k8s.PortForward(kc);
8+
9+
const namespace = process.argv[2] || 'default';
10+
const deploymentName = process.argv[3] || 'demo-deployment';
11+
const localPort = parseInt(process.argv[4] || '8080', 10);
12+
const remotePort = parseInt(process.argv[5] || '8080', 10);
13+
14+
// This creates a local server that forwards traffic to a deployment in Kubernetes
15+
// by resolving the deployment to its first ready pod and port-forwarding to that pod.
16+
// Usage: node port-forward-deployment.js [namespace] [deploymentName] [localPort] [remotePort]
17+
// Example: node port-forward-deployment.js default my-app 8080 3000
18+
// This is equivalent to: kubectl port-forward deployment/my-app 8080:3000 -n default
19+
20+
const server = net.createServer(async (socket) => {
21+
try {
22+
await forward.portForwardDeployment(namespace, deploymentName, [remotePort], socket, null, socket);
23+
} catch (error) {
24+
console.error(`Error port-forwarding to deployment ${namespace}/${deploymentName}:`, error.message);
25+
socket.destroy();
26+
}
27+
});
28+
29+
server.listen(localPort, '127.0.0.1', () => {
30+
console.log(`Port forward server listening on http://127.0.0.1:${localPort}`);
31+
console.log(`Forwarding to deployment: ${namespace}/${deploymentName}:${remotePort}`);
32+
console.log(`Press Ctrl+C to stop`);
33+
});
34+
35+
server.on('error', (error) => {
36+
console.error('Server error:', error);
37+
});
38+
39+
process.on('SIGINT', () => {
40+
console.log('\nShutting down port-forward server...');
41+
server.close();
42+
process.exit(0);
43+
});

examples/port-forward-service.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as k8s from '@kubernetes/client-node';
2+
import net from 'node:net';
3+
4+
const kc = new k8s.KubeConfig();
5+
kc.loadFromDefault();
6+
7+
const forward = new k8s.PortForward(kc);
8+
9+
const namespace = process.argv[2] || 'default';
10+
const serviceName = process.argv[3] || 'demo-service';
11+
const localPort = parseInt(process.argv[4] || '8080', 10);
12+
const remotePort = parseInt(process.argv[5] || '8080', 10);
13+
14+
// This creates a local server that forwards traffic to a service in Kubernetes
15+
// by resolving the service to its first ready pod and port-forwarding to that pod.
16+
// Usage: node port-forward-service.js [namespace] [serviceName] [localPort] [remotePort]
17+
// Example: node port-forward-service.js default my-service 8080 80
18+
// This is equivalent to: kubectl port-forward svc/my-service 8080:80 -n default
19+
20+
const server = net.createServer(async (socket) => {
21+
try {
22+
await forward.portForwardService(namespace, serviceName, [remotePort], socket, null, socket);
23+
} catch (error) {
24+
console.error(`Error port-forwarding to service ${namespace}/${serviceName}:`, error.message);
25+
socket.destroy();
26+
}
27+
});
28+
29+
server.listen(localPort, '127.0.0.1', () => {
30+
console.log(`Port forward server listening on http://127.0.0.1:${localPort}`);
31+
console.log(`Forwarding to service: ${namespace}/${serviceName}:${remotePort}`);
32+
console.log(`Press Ctrl+C to stop`);
33+
});
34+
35+
server.on('error', (error) => {
36+
console.error('Server error:', error);
37+
});
38+
39+
process.on('SIGINT', () => {
40+
console.log('\nShutting down port-forward server...');
41+
server.close();
42+
process.exit(0);
43+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as k8s from '@kubernetes/client-node';
2+
import net from 'node:net';
3+
4+
const kc = new k8s.KubeConfig();
5+
kc.loadFromDefault();
6+
7+
const forward = new k8s.PortForward(kc);
8+
9+
const namespace = process.argv[2] || 'default';
10+
const deploymentName = process.argv[3] || 'demo-deployment';
11+
const localPort = parseInt(process.argv[4] || '8080', 10);
12+
const remotePort = parseInt(process.argv[5] || '8080', 10);
13+
14+
// This creates a local server that forwards traffic to a deployment in Kubernetes
15+
// by resolving the deployment to its first ready pod and port-forwarding to that pod.
16+
// Usage: npx ts-node port-forward-deployment.ts [namespace] [deploymentName] [localPort] [remotePort]
17+
// Example: npx ts-node port-forward-deployment.ts default my-app 8080 3000
18+
// This is equivalent to: kubectl port-forward deployment/my-app 8080:3000 -n default
19+
20+
const server = net.createServer(async (socket) => {
21+
try {
22+
await forward.portForwardDeployment(namespace, deploymentName, [remotePort], socket, null, socket);
23+
} catch (error) {
24+
console.error(
25+
`Error port-forwarding to deployment ${namespace}/${deploymentName}:`,
26+
(error as Error).message,
27+
);
28+
socket.destroy();
29+
}
30+
});
31+
32+
server.listen(localPort, '127.0.0.1', () => {
33+
console.log(`Port forward server listening on http://127.0.0.1:${localPort}`);
34+
console.log(`Forwarding to deployment: ${namespace}/${deploymentName}:${remotePort}`);
35+
console.log(`Press Ctrl+C to stop`);
36+
});
37+
38+
server.on('error', (error: NodeJS.ErrnoException) => {
39+
console.error('Server error:', error);
40+
});
41+
42+
process.on('SIGINT', () => {
43+
console.log('\nShutting down port-forward server...');
44+
server.close();
45+
process.exit(0);
46+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as k8s from '@kubernetes/client-node';
2+
import net from 'node:net';
3+
4+
const kc = new k8s.KubeConfig();
5+
kc.loadFromDefault();
6+
7+
const forward = new k8s.PortForward(kc);
8+
9+
const namespace = process.argv[2] || 'default';
10+
const serviceName = process.argv[3] || 'demo-service';
11+
const localPort = parseInt(process.argv[4] || '8080', 10);
12+
const remotePort = parseInt(process.argv[5] || '8080', 10);
13+
14+
// This creates a local server that forwards traffic to a service in Kubernetes
15+
// by resolving the service to its first ready pod and port-forwarding to that pod.
16+
// Usage: npx ts-node port-forward-service.ts [namespace] [serviceName] [localPort] [remotePort]
17+
// Example: npx ts-node port-forward-service.ts default my-service 8080 80
18+
// This is equivalent to: kubectl port-forward svc/my-service 8080:80 -n default
19+
20+
const server = net.createServer(async (socket) => {
21+
try {
22+
await forward.portForwardService(namespace, serviceName, [remotePort], socket, null, socket);
23+
} catch (error) {
24+
console.error(`Error port-forwarding to service ${namespace}/${serviceName}:`, error.message);
25+
socket.destroy();
26+
}
27+
});
28+
29+
server.listen(localPort, '127.0.0.1', () => {
30+
console.log(`Port forward server listening on http://127.0.0.1:${localPort}`);
31+
console.log(`Forwarding to service: ${namespace}/${serviceName}:${remotePort}`);
32+
console.log(`Press Ctrl+C to stop`);
33+
});
34+
35+
server.on('error', (error: NodeJS.ErrnoException) => {
36+
console.error('Server error:', error);
37+
});
38+
39+
process.on('SIGINT', () => {
40+
console.log('\nShutting down port-forward server...');
41+
server.close();
42+
process.exit(0);
43+
});

src/portforward.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ import WebSocket from 'isomorphic-ws';
22
import querystring from 'node:querystring';
33
import stream from 'node:stream';
44

5+
import { AppsV1Api, CoreV1Api, V1Pod } from './gen/index.js';
56
import { KubeConfig } from './config.js';
67
import { WebSocketHandler, WebSocketInterface } from './web-socket-handler.js';
78

89
export class PortForward {
10+
private readonly config: KubeConfig;
911
private readonly handler: WebSocketInterface;
1012
private readonly disconnectOnErr: boolean;
1113

1214
// handler is a parameter really only for injecting for testing.
1315
constructor(config: KubeConfig, disconnectOnErr?: boolean, handler?: WebSocketInterface) {
16+
this.config = config;
1417
this.handler = handler || new WebSocketHandler(config);
1518
this.disconnectOnErr = disconnectOnErr === undefined ? true : disconnectOnErr;
1619
}
@@ -70,4 +73,128 @@ export class PortForward {
7073

7174
return WebSocketHandler.restartableHandleStandardInput(createWebSocket, input, 0, retryCount);
7275
}
76+
77+
/**
78+
* Port forward to a service by resolving to the first ready pod selected by the service's selector.
79+
*
80+
* @param namespace - The namespace of the service
81+
* @param serviceName - The name of the service
82+
* @param targetPorts - The target ports to forward to
83+
* @param output - The writable stream for output
84+
* @param err - The writable stream for error output (can be null)
85+
* @param input - The readable stream for input
86+
* @param retryCount - The number of times to retry the connection
87+
* @throws Will throw an error if the service is not found or has no ready pods
88+
*/
89+
public async portForwardService(
90+
namespace: string,
91+
serviceName: string,
92+
targetPorts: number[],
93+
output: stream.Writable,
94+
err: stream.Writable | null,
95+
input: stream.Readable,
96+
retryCount: number = 0,
97+
): Promise<WebSocket.WebSocket | (() => WebSocket.WebSocket | null)> {
98+
const coreApi = this.config.makeApiClient(CoreV1Api);
99+
const service = await coreApi.readNamespacedService({ name: serviceName, namespace });
100+
101+
if (!service.spec?.selector || Object.keys(service.spec.selector).length === 0) {
102+
throw new Error(`Service ${namespace}/${serviceName} has no selector defined`);
103+
}
104+
105+
const labelSelector = this.buildLabelSelector(service.spec.selector);
106+
const pod = await this.getFirstReadyPod(namespace, labelSelector);
107+
108+
return this.portForward(namespace, pod.metadata!.name!, targetPorts, output, err, input, retryCount);
109+
}
110+
111+
/**
112+
* Port forward to a deployment by resolving to the first ready pod selected by the deployment's selector.
113+
*
114+
* @param namespace - The namespace of the deployment
115+
* @param deploymentName - The name of the deployment
116+
* @param targetPorts - The target ports to forward to
117+
* @param output - The writable stream for output
118+
* @param err - The writable stream for error output (can be null)
119+
* @param input - The readable stream for input
120+
* @param retryCount - The number of times to retry the connection
121+
* @throws Will throw an error if the deployment is not found or has no ready pods
122+
*/
123+
public async portForwardDeployment(
124+
namespace: string,
125+
deploymentName: string,
126+
targetPorts: number[],
127+
output: stream.Writable,
128+
err: stream.Writable | null,
129+
input: stream.Readable,
130+
retryCount: number = 0,
131+
): Promise<WebSocket.WebSocket | (() => WebSocket.WebSocket | null)> {
132+
const appsApi = this.config.makeApiClient(AppsV1Api);
133+
const deployment = await appsApi.readNamespacedDeployment({ name: deploymentName, namespace });
134+
135+
if (
136+
!deployment.spec?.selector?.matchLabels ||
137+
Object.keys(deployment.spec.selector.matchLabels).length === 0
138+
) {
139+
throw new Error(`Deployment ${namespace}/${deploymentName} has no selector defined`);
140+
}
141+
142+
const labelSelector = this.buildLabelSelector(deployment.spec.selector.matchLabels);
143+
const pod = await this.getFirstReadyPod(namespace, labelSelector);
144+
145+
return this.portForward(namespace, pod.metadata!.name!, targetPorts, output, err, input, retryCount);
146+
}
147+
148+
/**
149+
* Get the first ready pod matching the label selector.
150+
*
151+
* @param namespace - The namespace to query
152+
* @param labelSelector - The label selector to filter pods
153+
* @returns The first ready pod
154+
* @throws Will throw an error if no ready pods are found
155+
*/
156+
private async getFirstReadyPod(namespace: string, labelSelector: string): Promise<V1Pod> {
157+
const coreApi = this.config.makeApiClient(CoreV1Api);
158+
const podList = await coreApi.listNamespacedPod({ namespace, labelSelector });
159+
160+
if (!podList.items || podList.items.length === 0) {
161+
throw new Error(`No pods found with selector "${labelSelector}" in namespace ${namespace}`);
162+
}
163+
164+
// Find the first pod with Ready status
165+
for (const pod of podList.items) {
166+
if (this.isPodReady(pod)) {
167+
return pod;
168+
}
169+
}
170+
171+
throw new Error(`No ready pods found with selector "${labelSelector}" in namespace ${namespace}`);
172+
}
173+
174+
/**
175+
* Check if a pod is ready by looking at its status conditions.
176+
*
177+
* @param pod - The pod to check
178+
* @returns True if the pod has a Ready condition with status True
179+
*/
180+
private isPodReady(pod: V1Pod): boolean {
181+
if (!pod.status?.conditions) {
182+
return false;
183+
}
184+
return pod.status.conditions.some(
185+
(condition) => condition.type === 'Ready' && condition.status === 'True',
186+
);
187+
}
188+
189+
/**
190+
* Build a Kubernetes label selector string from a label object.
191+
*
192+
* @param labels - An object of label key-value pairs
193+
* @returns A Kubernetes label selector string
194+
*/
195+
private buildLabelSelector(labels: { [key: string]: string }): string {
196+
return Object.entries(labels)
197+
.map(([key, value]) => `${key}=${value}`)
198+
.join(',');
199+
}
73200
}

0 commit comments

Comments
 (0)