Skip to content

Commit 32d5bc8

Browse files
authored
feat(ui): gateway endpoint display and CRD installation UI (#114)
1 parent aff3083 commit 32d5bc8

13 files changed

Lines changed: 763 additions & 69 deletions

File tree

backend/src/routes/installation.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,117 @@ describe('Installation Provider Routes', () => {
174174
});
175175
});
176176
});
177+
178+
describe('Gateway Installation Routes', () => {
179+
const restores: Array<() => void> = [];
180+
181+
afterEach(() => {
182+
restores.forEach((r) => r());
183+
restores.length = 0;
184+
});
185+
186+
// ==========================================================================
187+
// GET /api/installation/gateway/status
188+
// ==========================================================================
189+
190+
describe('GET /api/installation/gateway/status', () => {
191+
test('returns gateway CRD status when CRDs are installed', async () => {
192+
restores.push(
193+
mockServiceMethod(kubernetesService, 'checkGatewayCRDStatus', async () => ({
194+
gatewayApiInstalled: true,
195+
inferenceExtInstalled: true,
196+
pinnedVersion: 'v1.3.1',
197+
gatewayAvailable: true,
198+
gatewayEndpoint: '10.0.0.50',
199+
message: 'Gateway API and Inference Extension CRDs are installed. Gateway is available.',
200+
installCommands: [
201+
'kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/latest/download/standard-install.yaml',
202+
'kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v1.3.1/manifests.yaml',
203+
],
204+
})),
205+
);
206+
207+
const res = await app.request('/api/installation/gateway/status');
208+
expect(res.status).toBe(200);
209+
210+
const data = await res.json();
211+
expect(data.gatewayApiInstalled).toBe(true);
212+
expect(data.inferenceExtInstalled).toBe(true);
213+
expect(data.pinnedVersion).toBe('v1.3.1');
214+
expect(data.gatewayAvailable).toBe(true);
215+
expect(data.gatewayEndpoint).toBe('10.0.0.50');
216+
expect(data.installCommands).toHaveLength(2);
217+
});
218+
219+
test('returns status when CRDs are not installed', async () => {
220+
restores.push(
221+
mockServiceMethod(kubernetesService, 'checkGatewayCRDStatus', async () => ({
222+
gatewayApiInstalled: false,
223+
inferenceExtInstalled: false,
224+
pinnedVersion: 'v1.3.1',
225+
gatewayAvailable: false,
226+
message: 'Gateway API and Inference Extension CRDs are not installed.',
227+
installCommands: [
228+
'kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/latest/download/standard-install.yaml',
229+
'kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v1.3.1/manifests.yaml',
230+
],
231+
})),
232+
);
233+
234+
const res = await app.request('/api/installation/gateway/status');
235+
expect(res.status).toBe(200);
236+
237+
const data = await res.json();
238+
expect(data.gatewayApiInstalled).toBe(false);
239+
expect(data.inferenceExtInstalled).toBe(false);
240+
expect(data.gatewayAvailable).toBe(false);
241+
});
242+
});
243+
244+
// ==========================================================================
245+
// POST /api/installation/gateway/install-crds
246+
// ==========================================================================
247+
248+
describe('POST /api/installation/gateway/install-crds', () => {
249+
test('returns 200 on successful CRD installation', async () => {
250+
restores.push(
251+
mockServiceMethod(helmService, 'applyManifestUrl', async () => ({
252+
success: true,
253+
stdout: 'customresourcedefinition.apiextensions.k8s.io/gateways.gateway.networking.k8s.io created',
254+
stderr: '',
255+
exitCode: 0,
256+
})),
257+
);
258+
259+
const res = await app.request('/api/installation/gateway/install-crds', { method: 'POST' });
260+
expect(res.status).toBe(200);
261+
262+
const data = await res.json();
263+
expect(data.success).toBe(true);
264+
expect(data.results).toHaveLength(2);
265+
expect(data.results[0].step).toBe('gateway-api-crds');
266+
expect(data.results[1].step).toBe('inference-extension-crds');
267+
});
268+
269+
test('returns 500 when Gateway API CRD installation fails', async () => {
270+
let callCount = 0;
271+
restores.push(
272+
mockServiceMethod(helmService, 'applyManifestUrl', async () => {
273+
callCount++;
274+
if (callCount === 1) {
275+
return {
276+
success: false,
277+
stdout: '',
278+
stderr: 'connection refused',
279+
exitCode: 1,
280+
};
281+
}
282+
return { success: true, stdout: 'ok', stderr: '', exitCode: 0 };
283+
}),
284+
);
285+
286+
const res = await app.request('/api/installation/gateway/install-crds', { method: 'POST' });
287+
expect(res.status).toBe(500);
288+
});
289+
});
290+
});

backend/src/routes/installation.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,57 @@ const installation = new Hono()
257257
message: `Failed to remove CRDs: ${error instanceof Error ? error.message : 'Unknown error'}`,
258258
});
259259
}
260+
})
261+
.get('/gateway/status', async (c) => {
262+
const status = await kubernetesService.checkGatewayCRDStatus();
263+
return c.json(status);
264+
})
265+
.post('/gateway/install-crds', async (c) => {
266+
const { GATEWAY_API_CRD_URL, GAIE_CRD_URL, PINNED_GAIE_VERSION } = await import('@kubeairunway/shared');
267+
268+
const results: Array<{ step: string; success: boolean; output: string; error?: string }> = [];
269+
270+
// Install Gateway API CRDs
271+
logger.info('Installing Gateway API CRDs');
272+
const gwResult = await helmService.applyManifestUrl(GATEWAY_API_CRD_URL, (data, stream) => {
273+
logger.debug({ stream }, data.trim());
274+
});
275+
results.push({
276+
step: 'gateway-api-crds',
277+
success: gwResult.success,
278+
output: gwResult.stdout,
279+
error: gwResult.stderr || undefined,
280+
});
281+
282+
if (!gwResult.success) {
283+
throw new HTTPException(500, {
284+
message: `Failed to install Gateway API CRDs: ${gwResult.stderr}`,
285+
});
286+
}
287+
288+
// Install GAIE CRDs
289+
logger.info(`Installing Inference Extension CRDs (${PINNED_GAIE_VERSION})`);
290+
const gaieResult = await helmService.applyManifestUrl(GAIE_CRD_URL, (data, stream) => {
291+
logger.debug({ stream }, data.trim());
292+
});
293+
results.push({
294+
step: 'inference-extension-crds',
295+
success: gaieResult.success,
296+
output: gaieResult.stdout,
297+
error: gaieResult.stderr || undefined,
298+
});
299+
300+
if (!gaieResult.success) {
301+
throw new HTTPException(500, {
302+
message: `Failed to install Inference Extension CRDs: ${gaieResult.stderr}`,
303+
});
304+
}
305+
306+
return c.json({
307+
success: true,
308+
message: 'Gateway API and Inference Extension CRDs installed successfully',
309+
results,
310+
});
260311
});
261312

262313
export default installation;

backend/src/services/helm.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,16 @@ class HelmService {
655655
getGpuOperatorCommands(): string[] {
656656
return this.getInstallCommands([GPU_OPERATOR_REPO], [GPU_OPERATOR_CHART]);
657657
}
658+
659+
/**
660+
* Apply a manifest from a URL using kubectl apply -f
661+
*/
662+
async applyManifestUrl(
663+
url: string,
664+
onStream?: StreamCallback
665+
): Promise<HelmResult> {
666+
return this.executeKubectl(['apply', '-f', url], onStream);
667+
}
658668
}
659669

660670
// Export singleton instance

backend/src/services/kubernetes.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as k8s from '@kubernetes/client-node';
22
import { configService } from './config';
3-
import type { DeploymentStatus, PodStatus, ClusterStatus, PodPhase, DeploymentConfig, RuntimeStatus, ModelDeployment, GatewayInfo, GatewayModelInfo } from '@kubeairunway/shared';
3+
import type { DeploymentStatus, PodStatus, ClusterStatus, PodPhase, DeploymentConfig, RuntimeStatus, ModelDeployment, GatewayInfo, GatewayModelInfo, GatewayCRDStatus } from '@kubeairunway/shared';
44
import { toModelDeploymentManifest, toDeploymentStatus } from '@kubeairunway/shared';
55
import { withRetry } from '../lib/retry';
66
import logger from '../lib/logger';
@@ -1480,6 +1480,108 @@ class KubernetesService {
14801480

14811481
return models;
14821482
}
1483+
1484+
/**
1485+
* Check Gateway API and GAIE CRD installation status.
1486+
* Also includes live gateway availability info.
1487+
*/
1488+
async checkGatewayCRDStatus(): Promise<GatewayCRDStatus> {
1489+
const { PINNED_GAIE_VERSION, GAIE_CRD_URL, GATEWAY_API_CRD_URL } = await import('@kubeairunway/shared');
1490+
1491+
const [gatewayApiInstalled, inferenceExtInstalled] = await Promise.all([
1492+
this.checkCRDExists('gateways.gateway.networking.k8s.io'),
1493+
this.checkCRDExists('inferencepools.inference.networking.k8s.io'),
1494+
]);
1495+
1496+
// Get live gateway status
1497+
let gatewayAvailable = false;
1498+
let gatewayEndpoint: string | undefined;
1499+
if (gatewayApiInstalled && inferenceExtInstalled) {
1500+
try {
1501+
const gwStatus = await this.getGatewayStatus();
1502+
gatewayAvailable = gwStatus.available;
1503+
gatewayEndpoint = gwStatus.endpoint;
1504+
} catch {
1505+
// Gateway status check failed, not critical
1506+
}
1507+
}
1508+
1509+
const allInstalled = gatewayApiInstalled && inferenceExtInstalled;
1510+
let message: string;
1511+
if (allInstalled && gatewayAvailable) {
1512+
message = 'Gateway API and Inference Extension CRDs are installed. Gateway is available.';
1513+
} else if (allInstalled) {
1514+
message = 'Gateway API and Inference Extension CRDs are installed. No active gateway detected.';
1515+
} else if (!gatewayApiInstalled && !inferenceExtInstalled) {
1516+
message = 'Gateway API and Inference Extension CRDs are not installed.';
1517+
} else if (!gatewayApiInstalled) {
1518+
message = 'Gateway API CRDs are not installed.';
1519+
} else {
1520+
message = 'Inference Extension CRDs are not installed.';
1521+
}
1522+
1523+
return {
1524+
gatewayApiInstalled,
1525+
inferenceExtInstalled,
1526+
pinnedVersion: PINNED_GAIE_VERSION,
1527+
gatewayAvailable,
1528+
gatewayEndpoint,
1529+
message,
1530+
installCommands: [
1531+
`kubectl apply -f ${GATEWAY_API_CRD_URL}`,
1532+
`kubectl apply -f ${GAIE_CRD_URL}`,
1533+
],
1534+
};
1535+
}
1536+
1537+
/**
1538+
* Proxy a GET request to a Kubernetes service through the API server.
1539+
* This allows fetching service endpoints (e.g. /metrics) even when running off-cluster.
1540+
* Uses raw fetch instead of the generated client to support text/plain responses.
1541+
*/
1542+
async proxyServiceGet(serviceName: string, namespace: string, port: number, path: string): Promise<string> {
1543+
const cluster = this.kc.getCurrentCluster();
1544+
if (!cluster) {
1545+
throw new Error('No active Kubernetes cluster configured');
1546+
}
1547+
1548+
// Build proxy URL: /api/v1/namespaces/{ns}/services/{name}:{port}/proxy/{path}
1549+
const proxyUrl = `${cluster.server}/api/v1/namespaces/${encodeURIComponent(namespace)}/services/${encodeURIComponent(serviceName)}:${port}/proxy/${path}`;
1550+
1551+
// Extract auth headers from KubeConfig
1552+
const reqOpts: { headers: Record<string, string>; strictSSL?: boolean } = { headers: {} };
1553+
await this.kc.applyToRequest(reqOpts as any);
1554+
1555+
// Extract TLS options (CA cert, client cert/key) from KubeConfig
1556+
const httpsOpts: { ca?: Buffer; cert?: Buffer; key?: Buffer; rejectUnauthorized?: boolean } = {};
1557+
this.kc.applyToHTTPSOptions(httpsOpts as any);
1558+
1559+
const tlsOpts: Record<string, any> = {};
1560+
if (httpsOpts.ca) tlsOpts.ca = httpsOpts.ca;
1561+
if (httpsOpts.cert) tlsOpts.cert = httpsOpts.cert;
1562+
if (httpsOpts.key) tlsOpts.key = httpsOpts.key;
1563+
if (cluster.skipTLSVerify || httpsOpts.rejectUnauthorized === false) {
1564+
tlsOpts.rejectUnauthorized = false;
1565+
}
1566+
1567+
const fetchOpts: RequestInit & { tls?: Record<string, any> } = {
1568+
method: 'GET',
1569+
headers: {
1570+
...reqOpts.headers,
1571+
'Accept': 'text/plain',
1572+
},
1573+
};
1574+
1575+
if (Object.keys(tlsOpts).length > 0) {
1576+
fetchOpts.tls = tlsOpts;
1577+
}
1578+
1579+
const response = await fetch(proxyUrl, fetchOpts);
1580+
if (!response.ok) {
1581+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1582+
}
1583+
return await response.text();
1584+
}
14831585
}
14841586

14851587
export const kubernetesService = new KubernetesService();

backend/src/services/metrics.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,24 +52,26 @@ describe('MetricsService - Error Message Handling', () => {
5252
// Test error message mapping logic
5353
function mapErrorMessage(errorMessage: string): string {
5454
if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
55-
return 'Cannot resolve service DNS. KubeAIRunway must be running in-cluster to fetch metrics.';
55+
return 'Cannot resolve service DNS. The deployment service may not exist yet.';
5656
} else if (errorMessage.includes('ECONNREFUSED')) {
5757
return 'Connection refused. The deployment may not be ready yet.';
5858
} else if (errorMessage.includes('abort')) {
5959
return 'Request timed out. The deployment may be under heavy load or not responding.';
60-
} else if (errorMessage.includes('HTTP 404')) {
60+
} else if (errorMessage.includes('HTTP 404') || errorMessage.includes('404')) {
6161
return 'Metrics endpoint not found. The deployment may not expose metrics.';
62-
} else if (errorMessage.includes('HTTP 503')) {
62+
} else if (errorMessage.includes('HTTP 503') || errorMessage.includes('503')) {
6363
return 'Service unavailable. The deployment is starting up.';
6464
} else if (errorMessage.includes('fetch failed') || errorMessage.includes('TypeError')) {
65-
return 'Cannot connect to metrics endpoint. KubeAIRunway must be running in-cluster.';
65+
return 'Cannot connect to metrics endpoint. Verify the deployment is running.';
66+
} else if (errorMessage.includes('connect ECONNREFUSED') || errorMessage.includes('no cluster')) {
67+
return 'Cannot connect to the Kubernetes cluster. Check your kubeconfig.';
6668
}
6769
return errorMessage;
6870
}
6971

7072
test('maps DNS resolution errors', () => {
7173
expect(mapErrorMessage('getaddrinfo ENOTFOUND service.namespace.svc')).toContain('Cannot resolve service DNS');
72-
expect(mapErrorMessage('Error: ENOTFOUND')).toContain('in-cluster');
74+
expect(mapErrorMessage('Error: ENOTFOUND')).toContain('not exist yet');
7375
});
7476

7577
test('maps connection refused errors', () => {
@@ -93,8 +95,8 @@ describe('MetricsService - Error Message Handling', () => {
9395
});
9496

9597
test('maps fetch errors', () => {
96-
expect(mapErrorMessage('fetch failed')).toContain('in-cluster');
97-
expect(mapErrorMessage('TypeError: Failed to fetch')).toContain('in-cluster');
98+
expect(mapErrorMessage('fetch failed')).toContain('Verify the deployment');
99+
expect(mapErrorMessage('TypeError: Failed to fetch')).toContain('Verify the deployment');
98100
});
99101

100102
test('returns original message for unknown errors', () => {

0 commit comments

Comments
 (0)