Skip to content

Commit 941cf2e

Browse files
committed
portforward start --svc filter; unit tests + CI test gate
1 parent 2f865bb commit 941cf2e

5 files changed

Lines changed: 211 additions & 13 deletions

File tree

.github/workflows/release.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ permissions:
88
contents: write
99

1010
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: oven-sh/setup-bun@v2
16+
- run: bun test
17+
1118
build:
19+
needs: test
1220
runs-on: ubuntu-latest
1321
strategy:
1422
matrix:

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ git clone https://github.com/edenlabllc/portforward port-forward
3232
cd port-forward
3333
./install.sh # compiles dist/portforward and installs it
3434
# or just: bun run build # produces dist/portforward
35+
bun test # run the unit tests
3536
```
3637

3738
> No `bun install` is required — the project has no runtime dependencies.
@@ -63,6 +64,16 @@ portforward start
6364
- `.portforward.yml`
6465
- `.workspace.yaml`
6566

67+
### Selecting services
68+
69+
By default `start` runs every service in the config. Use `--svc` (repeatable) to run only some. The term is matched as a case-insensitive substring of the service name, so `postgres` matches `postgres-cluster-pooler` and `clickhouse` matches both `clickhouse-http` and `clickhouse-native`:
70+
71+
```bash
72+
portforward start --svc minio --svc postgres
73+
```
74+
75+
An unknown term fails fast and lists the available services.
76+
6677
## Config
6778

6879
```yaml

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
},
99
"scripts": {
1010
"build": "bun build --compile src/cli.ts --outfile dist/portforward",
11+
"test": "bun test",
1112
"start": "bun src/cli.ts start",
1213
"init": "bun src/cli.ts init",
1314
"check": "bun src/cli.ts check"

src/cli.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
finalizeConfig,
4+
isBenignForwardError,
5+
nextReconnectDelay,
6+
parseConfig,
7+
selectServices,
8+
validateConfig,
9+
type ServiceConfig,
10+
} from "./cli.ts";
11+
12+
function service(overrides: Partial<ServiceConfig> = {}): ServiceConfig {
13+
return { name: "api", namespace: "default", pod: "api", localPort: 8080, remotePort: 8080, ...overrides };
14+
}
15+
16+
describe("parseConfig", () => {
17+
test("parses the preferred services mapping", () => {
18+
const config = parseConfig(`name: local-dev
19+
services:
20+
api:
21+
namespace: default
22+
localPort: 8080
23+
remotePort: 8080
24+
postgres:
25+
namespace: default
26+
localPort: 5432
27+
remotePort: 5432
28+
`);
29+
finalizeConfig(config);
30+
31+
expect(config.name).toBe("local-dev");
32+
expect(config.services).toHaveLength(2);
33+
expect(config.services[0]).toMatchObject({ name: "api", namespace: "default", localPort: 8080, remotePort: 8080 });
34+
// pod defaults to the service key when omitted
35+
expect(config.services[0]!.pod).toBe("api");
36+
});
37+
38+
test("normalizes `service:` to a service/ resource", () => {
39+
const config = parseConfig(`services:
40+
api:
41+
namespace: default
42+
service: api-svc
43+
localPort: 80
44+
remotePort: 80
45+
`);
46+
expect(config.services[0]!.resource).toBe("service/api-svc");
47+
});
48+
49+
test("supports legacy portforwards arrays", () => {
50+
const config = parseConfig(`name: legacy
51+
portforwards:
52+
- name: api
53+
namespace: default
54+
pod: api
55+
localPort: 8080
56+
remotePort: 8080
57+
`);
58+
expect(config.services).toHaveLength(1);
59+
expect(config.services[0]).toMatchObject({ name: "api", pod: "api", localPort: 8080 });
60+
});
61+
62+
test("parses JSON config", () => {
63+
const config = parseConfig(`{"name":"j","services":{"api":{"namespace":"default","pod":"api","localPort":1,"remotePort":2}}}`);
64+
expect(config.name).toBe("j");
65+
expect(config.services[0]).toMatchObject({ name: "api", localPort: 1, remotePort: 2 });
66+
});
67+
68+
test("strips inline comments but keeps `#` inside quotes", () => {
69+
const config = parseConfig(`services:
70+
api:
71+
namespace: default # forwarded api
72+
pod: "a#b"
73+
localPort: 80
74+
remotePort: 80
75+
`);
76+
expect(config.services[0]!.namespace).toBe("default");
77+
expect(config.services[0]!.pod).toBe("a#b");
78+
});
79+
});
80+
81+
describe("selectServices", () => {
82+
const services = [
83+
service({ name: "minio" }),
84+
service({ name: "postgres-cluster-pooler" }),
85+
service({ name: "clickhouse-http" }),
86+
service({ name: "clickhouse-native" }),
87+
];
88+
89+
test("returns all when no terms given", () => {
90+
expect(selectServices(services, [])).toHaveLength(4);
91+
});
92+
93+
test("matches by case-insensitive substring", () => {
94+
const picked = selectServices(services, ["postgres", "MINIO"]).map((s) => s.name);
95+
expect(picked).toEqual(["minio", "postgres-cluster-pooler"]);
96+
});
97+
98+
test("a single term can select a group", () => {
99+
const picked = selectServices(services, ["clickhouse"]).map((s) => s.name);
100+
expect(picked).toEqual(["clickhouse-http", "clickhouse-native"]);
101+
});
102+
103+
test("throws on a term that matches nothing", () => {
104+
expect(() => selectServices(services, ["nope"])).toThrow(/no service matches: nope/);
105+
});
106+
});
107+
108+
describe("validateConfig", () => {
109+
test("accepts a complete service", () => {
110+
expect(() => validateConfig({ services: [service()] }, "cfg")).not.toThrow();
111+
});
112+
113+
test("requires namespace", () => {
114+
expect(() => validateConfig({ services: [service({ namespace: undefined as unknown as string })] }, "cfg"))
115+
.toThrow(/namespace is required/);
116+
});
117+
118+
test("requires a pod or resource", () => {
119+
expect(() => validateConfig({ services: [service({ pod: undefined, resource: undefined })] }, "cfg"))
120+
.toThrow(/pod or .*resource is required/);
121+
});
122+
123+
test("requires integer ports", () => {
124+
expect(() => validateConfig({ services: [service({ localPort: NaN })] }, "cfg"))
125+
.toThrow(/localPort is required/);
126+
});
127+
});
128+
129+
describe("nextReconnectDelay", () => {
130+
test("doubles up to a 30s ceiling", () => {
131+
expect(nextReconnectDelay(2000)).toBe(4000);
132+
expect(nextReconnectDelay(16000)).toBe(30000);
133+
expect(nextReconnectDelay(30000)).toBe(30000);
134+
});
135+
});
136+
137+
describe("isBenignForwardError", () => {
138+
test("flags per-connection copy resets", () => {
139+
expect(isBenignForwardError("E0602 ... error copying from local connection to remote stream: ... reset by peer")).toBe(true);
140+
});
141+
142+
test("keeps real errors visible", () => {
143+
expect(isBenignForwardError("error: lost connection to pod")).toBe(false);
144+
});
145+
});

src/cli.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const VERSION = typeof PORTFORWARD_VERSION !== "undefined" ? PORTFORWARD_VERSION
99
const REPO = process.env.PORTFORWARD_REPO
1010
?? (typeof PORTFORWARD_REPO !== "undefined" ? PORTFORWARD_REPO : "edenlabllc/portforward");
1111

12-
type ServiceConfig = {
12+
export type ServiceConfig = {
1313
name: string;
1414
namespace: string;
1515
pod?: string;
@@ -19,7 +19,7 @@ type ServiceConfig = {
1919
remotePort: number;
2020
};
2121

22-
type AppConfig = {
22+
export type AppConfig = {
2323
name?: string;
2424
services: ServiceConfig[];
2525
};
@@ -48,13 +48,15 @@ async function main() {
4848
const config = await loadConfig(configPath);
4949
if (config.services.length === 0) fail(`No services configured in ${configPath}`);
5050

51+
const selected = selectServices(config.services, getOptions(args, "--svc"));
52+
5153
console.log(`portforward: ${config.name ?? configPath}`);
5254
console.log(`config: ${configPath}`);
53-
console.log(`services: ${config.services.map((service) => service.name).join(", ")}`);
55+
console.log(`services: ${selected.map((service) => service.name).join(", ")}`);
5456
console.log("Press Ctrl+C to stop.");
5557

5658
installSignalHandlers();
57-
await Promise.all(config.services.map((service) => keepAlive(service)));
59+
await Promise.all(selected.map((service) => keepAlive(service)));
5860
return;
5961
}
6062

@@ -97,15 +99,42 @@ function printHelp() {
9799
Usage:
98100
portforward init [--config portforward.yaml] [--force]
99101
portforward check [--config portforward.yaml]
100-
portforward start [--config portforward.yaml]
102+
portforward start [--config portforward.yaml] [--svc NAME]...
101103
portforward upgrade download and install the latest release
102104
portforward version
103105
106+
--svc NAME run only matching services (substring match, repeatable);
107+
omit to start all. e.g. --svc minio --svc postgres
108+
104109
Config files are searched in this order:
105110
${CONFIG_FILES.join("\n ")}
106111
`);
107112
}
108113

114+
function getOptions(args: string[], name: string) {
115+
const values: string[] = [];
116+
for (let index = 0; index < args.length; index++) {
117+
if (args[index] === name && args[index + 1] !== undefined) values.push(args[index + 1]!);
118+
}
119+
return values;
120+
}
121+
122+
export function selectServices(services: ServiceConfig[], terms: string[]) {
123+
if (terms.length === 0) return services;
124+
125+
const needles = terms.map((term) => term.toLowerCase());
126+
const matches = (service: ServiceConfig) =>
127+
needles.some((needle) => service.name.toLowerCase().includes(needle));
128+
const unmatched = needles.filter((needle) =>
129+
!services.some((service) => service.name.toLowerCase().includes(needle)));
130+
131+
if (unmatched.length) {
132+
fail(`no service matches: ${unmatched.join(", ")}. available: ${services.map((service) => service.name).join(", ")}`);
133+
}
134+
135+
return services.filter(matches);
136+
}
137+
109138
function getOption(args: string[], name: string) {
110139
const index = args.indexOf(name);
111140
if (index === -1) return undefined;
@@ -179,13 +208,13 @@ async function loadConfig(path: string): Promise<AppConfig> {
179208
return parsed;
180209
}
181210

182-
function finalizeConfig(config: AppConfig) {
211+
export function finalizeConfig(config: AppConfig) {
183212
for (const service of config.services) {
184213
service.pod ??= service.name;
185214
}
186215
}
187216

188-
function parseConfig(text: string): AppConfig {
217+
export function parseConfig(text: string): AppConfig {
189218
const trimmed = text.trimStart();
190219
if (trimmed.startsWith("{")) return parseJsonConfig(trimmed);
191220
return parseYamlConfig(text);
@@ -337,7 +366,7 @@ function stripInlineComment(line: string) {
337366
return line.trimEnd();
338367
}
339368

340-
function validateConfig(config: AppConfig, path: string) {
369+
export function validateConfig(config: AppConfig, path: string) {
341370
for (const service of config.services) {
342371
if (!service.name) fail(`Invalid config ${path}: service without name.`);
343372
if (!service.namespace) fail(`Invalid config ${path}: ${service.name}.namespace is required.`);
@@ -522,12 +551,12 @@ async function streamLines(stream: ReadableStream | null, onLine: (line: string)
522551
}
523552
}
524553

525-
function isBenignForwardError(line: string) {
554+
export function isBenignForwardError(line: string) {
526555
return line.includes("error copying from local connection to remote stream")
527556
|| line.includes("error copying from remote stream to local connection");
528557
}
529558

530-
function nextReconnectDelay(current: number) {
559+
export function nextReconnectDelay(current: number) {
531560
return Math.min(current * 2, 30000);
532561
}
533562

@@ -554,8 +583,12 @@ function sleep(ms: number) {
554583
}
555584

556585
function fail(message: string): never {
557-
console.error(`portforward: ${message}`);
558-
process.exit(1);
586+
throw new Error(message);
559587
}
560588

561-
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
589+
if (import.meta.main) {
590+
main().catch((error) => {
591+
console.error(`portforward: ${error instanceof Error ? error.message : String(error)}`);
592+
process.exit(1);
593+
});
594+
}

0 commit comments

Comments
 (0)