UNPKG

kubernetes-fluent-client

Version:

A @kubernetes/client-node fluent API wrapper that leverages K8s Server Side Apply.

325 lines (282 loc) 10.8 kB
// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors import { KubeConfig, PatchStrategy } from "@kubernetes/client-node"; import { RequestInit } from "node-fetch"; import { URL } from "url"; import { Agent, Dispatcher } from "undici"; import { Agent as httpsAgent } from "https"; import { fetch } from "../fetch.js"; import { modelToGroupVersionKind } from "../kinds.js"; import { GenericClass } from "../types.js"; import { ApplyCfg, Filters, K8sConfigPromise, FetchMethods } from "./shared-types.js"; import fs from "fs"; import { V1Eviction as Eviction } from "@kubernetes/client-node"; const SSA_CONTENT_TYPE = "application/apply-patch+yaml"; const K8S_SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"; /** * Get the headers for a request * * @param token - the token from @kubernetes/client-node * @returns the headers for undici */ export async function getHeaders(token?: string | null): Promise<Record<string, string>> { let saToken: string | null = ""; if (!token) { saToken = await getToken(); } const headers: Record<string, string> = { "Content-Type": "application/json", "User-Agent": "kubernetes-fluent-client", }; if (token) { headers["Authorization"] = `Bearer ${token}`; } else if (saToken) { headers["Authorization"] = `Bearer ${saToken}`; } return headers; } /** * Get the agent for a request * * @param opts - the request options from node-fetch * @returns the agent for undici */ export function getHTTPSAgent(opts: RequestInit): Dispatcher | undefined { // In cluster there will be agent - testing or dev no const agentOptions = opts.agent instanceof httpsAgent ? { ca: opts.agent.options.ca, cert: opts.agent.options.cert, key: opts.agent.options.key, } : { ca: undefined, cert: undefined, key: undefined, }; return new Agent({ keepAliveMaxTimeout: 600000, keepAliveTimeout: 600000, bodyTimeout: 0, connect: agentOptions, }); } /** * Read the serviceAccount Token * * @returns token or null */ export async function getToken(): Promise<string | null> { try { return (await fs.promises.readFile(K8S_SA_TOKEN_PATH, "utf8")).trim(); } catch { return null; } } /** * Generate a path to a Kubernetes resource * * @param serverUrl - the URL of the Kubernetes API server * @param model - the model to use for the API * @param filters - (optional) filter overrides, can also be chained * @param excludeName - (optional) exclude the name from the path * @returns the path to the resource */ export function pathBuilder<T extends GenericClass>( serverUrl: string, model: T, filters: Filters, excludeName = false, ) { const matchedKind = filters.kindOverride || modelToGroupVersionKind(model.name); // If the kind is not specified and the model is not a KubernetesObject, throw an error if (!matchedKind) { throw new Error(`Kind not specified for ${model.name}`); } // Use the plural property if it exists, otherwise use lowercase kind + s const plural = matchedKind.plural || `${matchedKind.kind.toLowerCase()}s`; let base = "/api/v1"; // If the kind is not in the core group, add the group and version to the path if (matchedKind.group) { if (!matchedKind.version) { throw new Error(`Version not specified for ${model.name}`); } base = `/apis/${matchedKind.group}/${matchedKind.version}`; } // Namespaced paths require a namespace prefix const namespace = filters.namespace ? `namespaces/${filters.namespace}` : ""; // Name should not be included in some paths const name = excludeName ? "" : filters.name; // Build the complete path to the resource const path = [base, namespace, plural, name].filter(Boolean).join("/"); // Generate the URL object const url = new URL(path, serverUrl); // Add field selectors to the query params if (filters.fields) { const fieldSelector = Object.entries(filters.fields) .map(([key, value]) => `${key}=${value}`) .join(","); url.searchParams.set("fieldSelector", fieldSelector); } // Add label selectors to the query params if (filters.labels) { const labelSelector = Object.entries(filters.labels) // Exists set-based operators only include the key // See https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#set-based-requirement .map(([key, value]) => (value ? `${key}=${value}` : key)) .join(","); url.searchParams.set("labelSelector", labelSelector); } return url; } /** * Sets up the kubeconfig and https agent for a request * * A few notes: * - The kubeconfig is loaded from the default location, and can check for in-cluster config * - We have to create an agent to handle the TLS connection (for the custom CA + mTLS in some cases) * - The K8s lib uses request instead of node-fetch today so the object is slightly different * * @param method - the HTTP method to use * @returns the fetch options and server URL */ export async function k8sCfg(method: FetchMethods): K8sConfigPromise { const kubeConfig = new KubeConfig(); kubeConfig.loadFromDefault(); const cluster = kubeConfig.getCurrentCluster(); if (!cluster) { throw new Error("No currently active cluster"); } // Get TLS Options const opts = await kubeConfig.applyToFetchOptions({}); // Extract the headers from the options object const symbols = Object.getOwnPropertySymbols(opts.headers); const headersMap = symbols .map(symbol => Object.getOwnPropertyDescriptor(opts.headers, symbol)?.value) .find(value => typeof value === "object" && value !== null) as | Record<string, string[]> | undefined; // Extract the Authorization header const extractedHeaders: Record<string, string | undefined> = { Authorization: headersMap?.["Authorization"]?.[0]?.split(" ")[1], }; const undiciRequestUnit = { headers: await getHeaders(extractedHeaders["Authorization"]), method, dispatcher: getHTTPSAgent(opts), }; return { opts: undiciRequestUnit, serverUrl: cluster.server }; } const isEvictionPayload = (payload: unknown): payload is Eviction => payload !== null && payload !== undefined && typeof payload === "object" && "kind" in payload && (payload as { kind: string }).kind === "Eviction"; /** * Prepares and mutates the request options and URL for Kubernetes PATCH or APPLY operations. * * This function modifies the request's HTTP method, headers, and URL based on the operation type. * It handles the following: * * - `PATCH_STATUS`: Converts the method to `PATCH`, appends `/status` to the path, sets merge patch headers, * and rewrites the payload to contain only the `status` field. * - `PATCH`: Sets the content type to `application/json-patch+json`. * - `APPLY`: Converts the method to `PATCH`, sets server-side apply headers, and updates the query string * with field manager and force options. * * @template K * @param methodPayload - The original method and payload. May be mutated if `PATCH_STATUS` is used. * @param opts - The request options. * @param opts.method - The HTTP method (e.g. `PATCH`, `APPLY`, or `PATCH_STATUS`). * @param opts.headers - The headers to be updated with the correct content type. * @param url - The URL to mutate with subresource path or query parameters. * @param applyCfg - Server-side apply options, such as `force`. */ export function prepareRequestOptions<K>( methodPayload: MethodPayload<K>, opts: { method?: string; headers?: Record<string, string> }, url: URL, applyCfg: ApplyCfg, ): void { switch (opts.method) { // PATCH_STATUS is a special case that uses the PATCH method on status subresources case "PATCH_STATUS": opts.method = "PATCH"; url.pathname = `${url.pathname}/status`; (opts.headers as Record<string, string>)["Content-Type"] = PatchStrategy.MergePatch; methodPayload.payload = { status: (methodPayload.payload as { status: unknown }).status }; break; case "PATCH": (opts.headers as Record<string, string>)["Content-Type"] = PatchStrategy.JsonPatch; break; case "APPLY": (opts.headers as Record<string, string>)["Content-Type"] = SSA_CONTENT_TYPE; opts.method = "PATCH"; url.searchParams.set("fieldManager", "pepr"); url.searchParams.set("fieldValidation", "Strict"); url.searchParams.set("force", applyCfg.force ? "true" : "false"); break; } } export type MethodPayload<K> = { method: FetchMethods; payload?: K | unknown; }; /** * Execute a request against the Kubernetes API server. * * @param model - the model to use for the API * @param filters - (optional) filter overrides, can also be chained * @param methodPayload - method and payload for the request * @param applyCfg - (optional) configuration for the apply method * * @returns the parsed JSON response */ export async function k8sExec<T extends GenericClass, K>( model: T, filters: Filters, methodPayload: MethodPayload<K>, applyCfg: ApplyCfg = { force: false }, ) { const reconstruct = async (method: FetchMethods): K8sConfigPromise => { const configMethod = method === FetchMethods.LOG ? FetchMethods.GET : method; const { opts, serverUrl } = await k8sCfg(configMethod); // Build the base path once, using excludeName only for standard POST requests const shouldExcludeName = method === FetchMethods.POST && !(methodPayload.payload && isEvictionPayload(methodPayload.payload)); const baseUrl = pathBuilder(serverUrl.toString(), model, filters, shouldExcludeName); // Append appropriate subresource paths if (methodPayload.payload && isEvictionPayload(methodPayload.payload)) { baseUrl.pathname = `${baseUrl.pathname}/eviction`; } else if (method === FetchMethods.LOG) { baseUrl.pathname = `${baseUrl.pathname}/log`; } return { serverUrl: baseUrl, opts, }; }; const { opts, serverUrl } = await reconstruct(methodPayload.method); const url: URL = serverUrl instanceof URL ? serverUrl : new URL(serverUrl); prepareRequestOptions( methodPayload, opts as { method?: string; headers?: Record<string, string> }, url, applyCfg, ); if (methodPayload.payload) { opts.body = JSON.stringify(methodPayload.payload); } const resp = await fetch<K>(url, opts); if (resp.ok) { return resp.data; } if (resp.status === 404 && methodPayload.method === FetchMethods.PATCH_STATUS) { resp.statusText = "Not Found" + " (NOTE: This error is expected if the resource has no status subresource)"; } throw resp; }