Skip to content

Commit ca8f403

Browse files
committed
fix(backend): honour kubeconfig CA under Bun's native fetch
- Add `BunTlsHttpLibrary` and a `makeApiClient` helper in `kubeconfig.ts`. The SDK's default `IsomorphicFetchHttpLibrary` passes the kubeconfig CA via a Node.js `https.Agent`, which Bun's native `fetch` ignores — it only honours TLS material on the per-request `tls` option. This caused `UNABLE_TO_VERIFY_LEAF_SIGNATURE` on every request to clusters with a private CA (e.g. AKS). The subclass re-injects `ca`/`cert`/`key`/`rejectUnauthorized` via `tls`; auth headers are still applied upstream via `authMethods`. - Route all client construction in `kubernetes.ts`, `auth.ts`, `autoscaler.ts`, `config.ts`, `registry.ts`, and `secrets.ts` through `makeApiClient(...)` instead of `kc.makeApiClient(...)`, making the helper the single source of truth for Bun-safe TLS. Signed-off-by: Suraj Deshmukh <suraj.deshmukh@microsoft.com>
1 parent 4325784 commit ca8f403

7 files changed

Lines changed: 127 additions & 20 deletions

File tree

backend/src/lib/kubeconfig.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,110 @@ export function loadKubeConfig(): k8s.KubeConfig {
3333

3434
return kc;
3535
}
36+
37+
/**
38+
* Bun-compatible HTTP library for `@kubernetes/client-node`.
39+
*
40+
* WHY THIS EXISTS:
41+
* The client's default `IsomorphicFetchHttpLibrary` imports `node-fetch` and
42+
* passes the kubeconfig CA (and client cert/key) as a Node.js `https.Agent`
43+
* (`request.getAgent()`). Bun's runtime resolves `node-fetch` to its native
44+
* `fetch`, which **ignores** the Node `https.Agent` entirely — it only honours
45+
* TLS material supplied via the per-request `tls` option. The CA therefore never
46+
* reaches the TLS stack, so every request to a cluster whose API server uses a
47+
* private CA (e.g. AKS) fails with `UNABLE_TO_VERIFY_LEAF_SIGNATURE`.
48+
*
49+
* This subclass overrides `send()` to call Bun's native `fetch` directly,
50+
* translating the kubeconfig's TLS material into the `tls` option Bun
51+
* understands. Auth headers (Bearer tokens, etc.) are still applied by the
52+
* generated client via `authMethods` before `send()` runs, so we only need to
53+
* re-inject the TLS material here. It mirrors the working pattern already used
54+
* by `proxyServiceRequest` in `kubernetes.ts`.
55+
*
56+
* The response is wrapped exactly as the upstream library does: an `Observable`
57+
* constructed from a `Promise<ResponseContext>` (see `rxjsStub`), with a
58+
* `ResponseBody` exposing `text()` and `binary()`.
59+
*/
60+
export class BunTlsHttpLibrary extends k8s.IsomorphicFetchHttpLibrary {
61+
constructor(private readonly kc: k8s.KubeConfig) {
62+
super();
63+
}
64+
65+
send(request: k8s.RequestContext): k8s.Observable<k8s.ResponseContext> {
66+
const responsePromise = (async (): Promise<k8s.ResponseContext> => {
67+
// Extract TLS material (CA, client cert/key, verification mode) from the
68+
// kubeconfig the same way the SDK's Node path would, then hand it to Bun
69+
// via the `tls` option instead of a Node https.Agent.
70+
const httpsOptions: {
71+
ca?: Buffer;
72+
cert?: Buffer;
73+
key?: Buffer;
74+
rejectUnauthorized?: boolean;
75+
} = {};
76+
await this.kc.applyToHTTPSOptions(httpsOptions as any);
77+
78+
const tls: Record<string, unknown> = {};
79+
if (httpsOptions.ca) tls.ca = httpsOptions.ca;
80+
if (httpsOptions.cert) tls.cert = httpsOptions.cert;
81+
if (httpsOptions.key) tls.key = httpsOptions.key;
82+
if (this.kc.getCurrentCluster()?.skipTLSVerify || httpsOptions.rejectUnauthorized === false) {
83+
tls.rejectUnauthorized = false;
84+
}
85+
86+
const fetchOptions: RequestInit & { tls?: Record<string, unknown> } = {
87+
method: request.getHttpMethod().toString(),
88+
body: request.getBody() as BodyInit | undefined,
89+
headers: request.getHeaders(),
90+
signal: request.getSignal(),
91+
};
92+
if (Object.keys(tls).length > 0) {
93+
fetchOptions.tls = tls;
94+
}
95+
96+
const response = await fetch(request.getUrl(), fetchOptions);
97+
98+
const headers: Record<string, string> = {};
99+
response.headers.forEach((value, name) => {
100+
headers[name] = value;
101+
});
102+
103+
return new k8s.ResponseContext(response.status, headers, {
104+
text: () => response.text(),
105+
binary: async () => Buffer.from(await response.arrayBuffer()),
106+
});
107+
})();
108+
109+
return new k8s.Observable<k8s.ResponseContext>(responsePromise);
110+
}
111+
}
112+
113+
/**
114+
* Build a Kubernetes API client that works under Bun.
115+
*
116+
* Drop-in replacement for `kc.makeApiClient(ApiClass)`. It reproduces the SDK's
117+
* own `makeApiClient` wiring (`createConfiguration` with the kubeconfig as the
118+
* `default` auth method and a `ServerConfiguration` for the current cluster) but
119+
* swaps in {@link BunTlsHttpLibrary} so the kubeconfig CA is honoured on Bun's
120+
* native `fetch`.
121+
*
122+
* All backend services must construct their clients through this helper rather
123+
* than calling `kc.makeApiClient(...)` directly; otherwise requests to clusters
124+
* with a private CA fail with `UNABLE_TO_VERIFY_LEAF_SIGNATURE` under Bun.
125+
*/
126+
export function makeApiClient<T extends k8s.ApiType>(
127+
kc: k8s.KubeConfig,
128+
apiClientType: k8s.ApiConstructor<T>
129+
): T {
130+
const cluster = kc.getCurrentCluster();
131+
if (!cluster) {
132+
throw new Error('No active cluster!');
133+
}
134+
135+
const configuration = k8s.createConfiguration({
136+
baseServer: new k8s.ServerConfiguration(cluster.server, {}),
137+
authMethods: { default: kc },
138+
httpApi: new BunTlsHttpLibrary(kc),
139+
});
140+
141+
return new apiClientType(configuration);
142+
}

backend/src/services/auth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as k8s from '@kubernetes/client-node';
22
import * as os from 'os';
33
import * as path from 'path';
44
import * as fs from 'fs';
5-
import { loadKubeConfig } from '../lib/kubeconfig';
5+
import { loadKubeConfig, makeApiClient } from '../lib/kubeconfig';
66
import logger from '../lib/logger';
77

88
/**
@@ -46,7 +46,7 @@ class AuthService {
4646

4747
constructor() {
4848
this.kc = loadKubeConfig();
49-
this.authApi = this.kc.makeApiClient(k8s.AuthenticationV1Api);
49+
this.authApi = makeApiClient(this.kc, k8s.AuthenticationV1Api);
5050
}
5151

5252
/**

backend/src/services/autoscaler.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as k8s from '@kubernetes/client-node';
22
import type { AutoscalerDetectionResult, AutoscalerStatusInfo } from '@airunway/shared';
33
import { withRetry } from '../lib/retry';
4-
import { loadKubeConfig } from '../lib/kubeconfig';
4+
import { loadKubeConfig, makeApiClient } from '../lib/kubeconfig';
55
import logger from '../lib/logger';
66
import * as yaml from 'js-yaml';
77

@@ -13,9 +13,9 @@ class AutoscalerService {
1313

1414
constructor() {
1515
this.kc = loadKubeConfig();
16-
this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api);
17-
this.appsV1Api = this.kc.makeApiClient(k8s.AppsV1Api);
18-
this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi);
16+
this.coreV1Api = makeApiClient(this.kc, k8s.CoreV1Api);
17+
this.appsV1Api = makeApiClient(this.kc, k8s.AppsV1Api);
18+
this.customObjectsApi = makeApiClient(this.kc, k8s.CustomObjectsApi);
1919
}
2020

2121
/**

backend/src/services/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as k8s from '@kubernetes/client-node';
2-
import { loadKubeConfig } from '../lib/kubeconfig';
2+
import { loadKubeConfig, makeApiClient } from '../lib/kubeconfig';
33
import logger from '../lib/logger';
44

55
// Default namespace for AI Runway deployments
@@ -28,7 +28,7 @@ class ConfigService {
2828

2929
constructor() {
3030
this.kc = loadKubeConfig();
31-
this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api);
31+
this.coreV1Api = makeApiClient(this.kc, k8s.CoreV1Api);
3232
}
3333

3434
/**

backend/src/services/kubernetes.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { configService } from './config';
33
import type { DeploymentStatus, PodStatus, ClusterStatus, PodPhase, DeploymentConfig, RuntimeStatus, ModelDeployment, GatewayInfo, GatewayModelInfo, GatewayCRDStatus } from '@airunway/shared';
44
import { toModelDeploymentManifest, toDeploymentStatus, INFERENCE_GATEWAY_LABEL } from '@airunway/shared';
55
import { withRetry } from '../lib/retry';
6-
import { loadKubeConfig } from '../lib/kubeconfig';
6+
import { loadKubeConfig, makeApiClient } from '../lib/kubeconfig';
77
import logger from '../lib/logger';
88
import { aggregateRequiresCRDFromCapabilities, getAnnotatedProviderDisplayName, getProviderDisplayName, providerRequiresRuntimeCRD } from '../lib/providers';
99

@@ -221,9 +221,9 @@ class KubernetesService {
221221

222222
constructor() {
223223
this.kc = loadKubeConfig();
224-
this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi);
225-
this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api);
226-
this.apiExtensionsApi = this.kc.makeApiClient(k8s.ApiextensionsV1Api);
224+
this.customObjectsApi = makeApiClient(this.kc, k8s.CustomObjectsApi);
225+
this.coreV1Api = makeApiClient(this.kc, k8s.CoreV1Api);
226+
this.apiExtensionsApi = makeApiClient(this.kc, k8s.ApiextensionsV1Api);
227227
this.defaultNamespace = process.env.DEFAULT_NAMESPACE || 'airunway-system';
228228
}
229229

@@ -242,7 +242,7 @@ class KubernetesService {
242242
if (!userToken) {
243243
return this.customObjectsApi;
244244
}
245-
return this.createUserKubeConfig(userToken).makeApiClient(k8s.CustomObjectsApi);
245+
return makeApiClient(this.createUserKubeConfig(userToken), k8s.CustomObjectsApi);
246246
}
247247

248248
/**
@@ -252,7 +252,7 @@ class KubernetesService {
252252
if (!userToken) {
253253
return this.coreV1Api;
254254
}
255-
return this.createUserKubeConfig(userToken).makeApiClient(k8s.CoreV1Api);
255+
return makeApiClient(this.createUserKubeConfig(userToken), k8s.CoreV1Api);
256256
}
257257

258258
/**
@@ -261,7 +261,7 @@ class KubernetesService {
261261
private createUserClients(userToken: string) {
262262
const userKc = this.createUserKubeConfig(userToken);
263263
return {
264-
authorizationV1Api: userKc.makeApiClient(k8s.AuthorizationV1Api),
264+
authorizationV1Api: makeApiClient(userKc, k8s.AuthorizationV1Api),
265265
};
266266
}
267267

backend/src/services/registry.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as k8s from '@kubernetes/client-node';
2-
import { loadKubeConfig } from '../lib/kubeconfig';
2+
import { loadKubeConfig, makeApiClient } from '../lib/kubeconfig';
33
import logger from '../lib/logger';
44
import { withRetry } from '../lib/retry';
55

@@ -37,8 +37,8 @@ class RegistryService {
3737

3838
constructor() {
3939
this.kc = loadKubeConfig();
40-
this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api);
41-
this.appsV1Api = this.kc.makeApiClient(k8s.AppsV1Api);
40+
this.coreV1Api = makeApiClient(this.kc, k8s.CoreV1Api);
41+
this.appsV1Api = makeApiClient(this.kc, k8s.AppsV1Api);
4242
}
4343

4444
/**

backend/src/services/secrets.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as k8s from '@kubernetes/client-node';
2-
import { loadKubeConfig } from '../lib/kubeconfig';
2+
import { loadKubeConfig, makeApiClient } from '../lib/kubeconfig';
33
import logger from '../lib/logger';
44
import { withRetry } from '../lib/retry';
55
import type { HfSecretStatus, HfUserInfo } from '@airunway/shared';
@@ -31,7 +31,7 @@ class SecretsService {
3131

3232
constructor() {
3333
this.kc = loadKubeConfig();
34-
this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api);
34+
this.coreV1Api = makeApiClient(this.kc, k8s.CoreV1Api);
3535
}
3636

3737
/**

0 commit comments

Comments
 (0)