feat: add provider websocket http data plane
This commit is contained in:
@@ -46,7 +46,11 @@ RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 &&
|
||||
WORKDIR /app/src/components/microservices/code-queue
|
||||
COPY src/components/microservices/code-queue/package.json ./package.json
|
||||
RUN test -d node_modules/typescript || bun install
|
||||
WORKDIR /app
|
||||
COPY package.json /app/package.json
|
||||
RUN bun install
|
||||
COPY src/components/shared /app/src/components/shared
|
||||
WORKDIR /app/src/components/microservices/code-queue
|
||||
COPY src/components/microservices/code-queue/tsconfig.json ./tsconfig.json
|
||||
COPY src/components/microservices/code-queue/src ./src
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ services:
|
||||
K3SCTL_KUBE_API_LOCAL_PORT: "${K3SCTL_KUBE_API_LOCAL_PORT:-6443}"
|
||||
K3SCTL_KUBE_API_REMOTE_HOST: "${K3SCTL_KUBE_API_REMOTE_HOST:-127.0.0.1}"
|
||||
K3SCTL_KUBE_API_REMOTE_PORT: "${K3SCTL_KUBE_API_REMOTE_PORT:-6443}"
|
||||
K3SCTL_NATIVE_SERVICE_PROXY_ENABLED: "${K3SCTL_NATIVE_SERVICE_PROXY_ENABLED:-true}"
|
||||
K3SCTL_NATIVE_SERVICE_PROBE_TIMEOUT_MS: "${K3SCTL_NATIVE_SERVICE_PROBE_TIMEOUT_MS:-1200}"
|
||||
K3SCTL_NATIVE_SERVICE_FAILURE_COOLDOWN_MS: "${K3SCTL_NATIVE_SERVICE_FAILURE_COOLDOWN_MS:-10000}"
|
||||
K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE: "${K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE:-}"
|
||||
K3SCTL_NATIVE_SERVICE_URL_MDTODO: "${K3SCTL_NATIVE_SERVICE_URL_MDTODO:-}"
|
||||
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json}"
|
||||
K3SCTL_SERVICES_JSON: "${K3SCTL_SERVICES_JSON:-[]}"
|
||||
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}"
|
||||
|
||||
@@ -44,6 +44,9 @@ interface RuntimeConfig {
|
||||
kubeApiProxyEnabled: boolean;
|
||||
kubeconfigPath: string;
|
||||
kubeApiConnectHost: string;
|
||||
nativeServiceProxyEnabled: boolean;
|
||||
nativeServiceProbeTimeoutMs: number;
|
||||
nativeServiceFailureCooldownMs: number;
|
||||
requestTimeoutMs: number;
|
||||
healthTimeoutMs: number;
|
||||
services: ManagedService[];
|
||||
@@ -69,6 +72,7 @@ const logWriter = config.logFile
|
||||
})
|
||||
: null;
|
||||
const kubeClient = loadKubeApiClient();
|
||||
const nativeServiceFailures = new Map<string, number>();
|
||||
logWriter?.prune();
|
||||
|
||||
function envString(name: string, fallback: string): string {
|
||||
@@ -92,6 +96,11 @@ function envBool(name: string, fallback: boolean): boolean {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function envOptionalString(name: string): string | null {
|
||||
const value = process.env[name];
|
||||
return value === undefined || value.trim().length === 0 ? null : value.trim();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
@@ -259,6 +268,9 @@ function readConfig(): RuntimeConfig {
|
||||
kubeApiProxyEnabled: envBool("K3SCTL_KUBE_API_PROXY_ENABLED", true),
|
||||
kubeconfigPath: envString("K3SCTL_KUBECONFIG_PATH", "/var/lib/unidesk/k3s/kubeconfig"),
|
||||
kubeApiConnectHost: envString("K3SCTL_KUBE_API_CONNECT_HOST", "host.docker.internal"),
|
||||
nativeServiceProxyEnabled: envBool("K3SCTL_NATIVE_SERVICE_PROXY_ENABLED", true),
|
||||
nativeServiceProbeTimeoutMs: Math.max(250, Math.min(5000, envNumber("K3SCTL_NATIVE_SERVICE_PROBE_TIMEOUT_MS", 1200))),
|
||||
nativeServiceFailureCooldownMs: Math.max(1000, Math.min(60_000, envNumber("K3SCTL_NATIVE_SERVICE_FAILURE_COOLDOWN_MS", 10_000))),
|
||||
requestTimeoutMs: Math.max(1000, Math.min(120_000, envNumber("K3SCTL_REQUEST_TIMEOUT_MS", 30_000))),
|
||||
healthTimeoutMs: Math.max(500, Math.min(30_000, envNumber("K3SCTL_HEALTH_TIMEOUT_MS", 2500))),
|
||||
services: mergeServices([...manifestServices, ...inlineServices]),
|
||||
@@ -399,6 +411,37 @@ function endpointProxyApiPath(service: ManagedService, endpoint: ManagedEndpoint
|
||||
return `/api/v1/namespaces/${encodeURIComponent(namespace)}/services/${encodeURIComponent(serviceRef)}/proxy${safeTargetPath}`;
|
||||
}
|
||||
|
||||
function nativeServiceFailureKey(service: ManagedService): string {
|
||||
return `${service.namespace}/${service.id}`;
|
||||
}
|
||||
|
||||
function nativeServiceUrl(service: ManagedService, targetPath: string, query = ""): URL {
|
||||
const serviceName = routeString(service, "serviceName", service.id);
|
||||
const servicePort = routeNumber(service, "servicePort", 80);
|
||||
const safeTargetPath = targetPath.startsWith("/") ? targetPath : `/${targetPath}`;
|
||||
const override = envOptionalString(`K3SCTL_NATIVE_SERVICE_URL_${service.id.toUpperCase().replace(/[^A-Z0-9]/gu, "_")}`);
|
||||
const base = override ?? `http://${serviceName}.${service.namespace}.svc.cluster.local:${servicePort}`;
|
||||
const url = new URL(safeTargetPath, base.replace(/\/+$/u, "/"));
|
||||
url.search = query;
|
||||
return url;
|
||||
}
|
||||
|
||||
function nativeServiceProxyUsable(service: ManagedService): boolean {
|
||||
if (!config.nativeServiceProxyEnabled) return false;
|
||||
const failedAt = nativeServiceFailures.get(nativeServiceFailureKey(service));
|
||||
return failedAt === undefined || Date.now() - failedAt >= config.nativeServiceFailureCooldownMs;
|
||||
}
|
||||
|
||||
function rememberNativeServiceFailure(service: ManagedService, error: unknown): void {
|
||||
nativeServiceFailures.set(nativeServiceFailureKey(service), Date.now());
|
||||
log("warn", "native_service_proxy_failed", {
|
||||
serviceId: service.id,
|
||||
namespace: service.namespace,
|
||||
cooldownMs: config.nativeServiceFailureCooldownMs,
|
||||
error: errorToJson(error),
|
||||
});
|
||||
}
|
||||
|
||||
function kubernetesEndpointServiceRef(service: ManagedService, endpoint: ManagedEndpoint): { namespace: string; serviceRef: string } {
|
||||
const base = new URL(endpoint.baseUrl);
|
||||
if (base.protocol !== "kubernetes:") throw new Error(`endpoint ${endpoint.id} must use kubernetes:// baseUrl`);
|
||||
@@ -460,6 +503,48 @@ async function kubeApiServiceProxyResponse(
|
||||
return kubeApiProxyResponse(service, req, serviceProxyApiPath(service, targetPath), query, timeoutMs);
|
||||
}
|
||||
|
||||
async function nativeServiceProxyResponse(
|
||||
service: ManagedService,
|
||||
req: Request,
|
||||
targetPath: string,
|
||||
query: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response | null> {
|
||||
if (!nativeServiceProxyUsable(service)) return null;
|
||||
const upstreamUrl = nativeServiceUrl(service, targetPath, query);
|
||||
const headers = forwardHeaders(req);
|
||||
const bodyText = req.method === "GET" || req.method === "HEAD" ? "" : await req.text();
|
||||
const controller = new AbortController();
|
||||
const nativeTimeoutMs = Math.min(timeoutMs, config.nativeServiceProbeTimeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), nativeTimeoutMs);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: bodyText.length > 0 ? bodyText : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const body = await boundedText(upstream, 8 * 1024 * 1024);
|
||||
return new Response(body.text, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
|
||||
"x-unidesk-proxy-mode": "kubernetes-native-service",
|
||||
"x-unidesk-k3s-service": service.id,
|
||||
"x-unidesk-k3s-service-url": upstreamUrl.origin,
|
||||
"x-unidesk-upstream-duration-ms": String(Date.now() - startedAt),
|
||||
"x-unidesk-response-truncated": body.truncated ? "true" : "false",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
rememberNativeServiceFailure(service, error);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function kubeApiEndpointProxyResponse(
|
||||
service: ManagedService,
|
||||
endpoint: ManagedEndpoint,
|
||||
@@ -574,15 +659,13 @@ async function probeKubernetesServiceActive(service: ManagedService): Promise<Js
|
||||
async function probeKubernetesEndpoint(service: ManagedService, endpoint: ManagedEndpoint, active = false): Promise<JsonRecord> {
|
||||
if (!active && endpoint.healthMode === "pod-ready") return await probeKubernetesPodReady(service, endpoint);
|
||||
const checkedAt = new Date().toISOString();
|
||||
const response = active
|
||||
? await kubeApiServiceProxyResponse(
|
||||
service,
|
||||
new Request("http://k3sctl-adapter.local/health", { method: "GET", headers: { accept: "application/json" } }),
|
||||
endpoint.healthPath,
|
||||
"",
|
||||
config.healthTimeoutMs,
|
||||
)
|
||||
: await kubeApiEndpointProxyResponse(
|
||||
let response: Response;
|
||||
if (active) {
|
||||
const request = new Request("http://k3sctl-adapter.local/health", { method: "GET", headers: { accept: "application/json" } });
|
||||
response = await nativeServiceProxyResponse(service, request.clone(), endpoint.healthPath, "", config.healthTimeoutMs)
|
||||
?? await kubeApiServiceProxyResponse(service, request, endpoint.healthPath, "", config.healthTimeoutMs);
|
||||
} else {
|
||||
response = await kubeApiEndpointProxyResponse(
|
||||
service,
|
||||
endpoint,
|
||||
new Request("http://k3sctl-adapter.local/health", { method: "GET", headers: { accept: "application/json" } }),
|
||||
@@ -590,6 +673,7 @@ async function probeKubernetesEndpoint(service: ManagedService, endpoint: Manage
|
||||
"",
|
||||
config.healthTimeoutMs,
|
||||
);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
||||
const bodyText = await response.text();
|
||||
let body: JsonValue = bodyText.slice(0, 2000);
|
||||
@@ -605,7 +689,7 @@ async function probeKubernetesEndpoint(service: ManagedService, endpoint: Manage
|
||||
baseUrl: endpoint.baseUrl,
|
||||
healthPath: endpoint.healthPath,
|
||||
healthMode: endpoint.healthMode,
|
||||
proxyMode: "kubernetes-api-service-proxy",
|
||||
proxyMode: response.headers.get("x-unidesk-proxy-mode") ?? "kubernetes-api-service-proxy",
|
||||
route: service.route,
|
||||
healthy: response.ok,
|
||||
status: response.ok ? "healthy" : "unhealthy",
|
||||
@@ -761,7 +845,19 @@ async function controlPlaneSnapshot(): Promise<JsonRecord> {
|
||||
manifestPaths: config.manifestPaths,
|
||||
managedServicesHealthy,
|
||||
noFallback: true,
|
||||
runtimePath: "frontend -> backend-core -> k3sctl-adapter -> kubernetes api service proxy -> k3s service",
|
||||
runtimePath: config.nativeServiceProxyEnabled
|
||||
? "frontend -> backend-core -> provider websocket HTTP tunnel -> k3sctl-adapter -> kubernetes native service/DNS -> k3s service"
|
||||
: "frontend -> backend-core -> k3sctl-adapter -> kubernetes api service proxy -> k3s service",
|
||||
nativeServiceProxy: {
|
||||
enabled: config.nativeServiceProxyEnabled,
|
||||
mode: "kubernetes-native-service",
|
||||
failureCooldownMs: config.nativeServiceFailureCooldownMs,
|
||||
failedServices: Array.from(nativeServiceFailures.entries()).map(([key, failedAt]) => ({
|
||||
key,
|
||||
failedAt: new Date(failedAt).toISOString(),
|
||||
retryAfterMs: Math.max(0, config.nativeServiceFailureCooldownMs - (Date.now() - failedAt)),
|
||||
})),
|
||||
},
|
||||
kubeApiProxy: {
|
||||
enabled: config.kubeApiProxyEnabled,
|
||||
configured: kubeClient !== null,
|
||||
@@ -786,6 +882,8 @@ function forwardHeaders(request: Request): Headers {
|
||||
|
||||
async function proxyToService(service: ManagedService, req: Request, targetPath: string, query: string): Promise<Response> {
|
||||
if (isKubernetesServiceRoute(service)) {
|
||||
const native = await nativeServiceProxyResponse(service, req.clone(), targetPath, query, config.requestTimeoutMs);
|
||||
if (native !== null) return native;
|
||||
return kubeApiServiceProxyResponse(service, req, targetPath, query, config.requestTimeoutMs);
|
||||
}
|
||||
log("error", "k3sctl_route_not_kubernetes_service", { serviceId: service.id, route: service.route, noFallback: true });
|
||||
|
||||
Reference in New Issue
Block a user