UNPKG

kubernetes-fluent-client

Version:

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

387 lines (386 loc) 12.5 kB
// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023-Present The Kubernetes Fluent Client Authors import { StatusCodes } from "http-status-codes"; import { fetch } from "../fetch.js"; import { modelToGroupVersionKind } from "../kinds.js"; import { FetchMethods } from "./shared-types.js"; import { k8sCfg, k8sExec } from "./utils.js"; import { Watcher } from "./watch.js"; import { hasLogs } from "../helpers.js"; import { Pod } from "../upstream.js"; /** * Kubernetes fluent API inspired by Kubectl. Pass in a model, then call filters and actions on it. * * @param model - the model to use for the API * @param filters - (optional) filter overrides, can also be chained * @returns a fluent API for the model */ export function K8s(model, filters = {}) { const withFilters = { WithField, WithLabel, Get, Delete, Evict, Watch, Logs, Proxy, Scale, Finalize, }; const matchedKind = filters.kindOverride || modelToGroupVersionKind(model.name); /** * @inheritdoc * @see {@link K8sInit.InNamespace} */ function InNamespace(namespace) { if (filters.namespace) { throw new Error(`Namespace already specified: ${filters.namespace}`); } filters.namespace = namespace; return withFilters; } /** * @inheritdoc * @see {@link K8sInit.WithField} */ function WithField(key, value) { filters.fields = filters.fields || {}; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore filters.fields[key] = value; return withFilters; } /** * @inheritdoc * @see {@link K8sInit.WithLabel} */ function WithLabel(key, value = "") { filters.labels = filters.labels || {}; filters.labels[key] = value; return withFilters; } /** * Sync the filters with the provided payload. * * @param payload - the payload to sync with */ function syncFilters(payload) { // Ensure the payload has metadata payload.metadata = payload.metadata || {}; if (!filters.namespace) { filters.namespace = payload.metadata.namespace; } if (!filters.name) { filters.name = payload.metadata.name; } if (!payload.apiVersion) { payload.apiVersion = [matchedKind.group, matchedKind.version].filter(Boolean).join("/"); } if (!payload.kind) { payload.kind = matchedKind.kind; } } /** * @inheritdoc * @see {@link K8sInit.Logs} */ async function Logs(name) { let labels = {}; const { kind } = matchedKind; const { namespace } = filters; const podList = []; if (name) { if (filters.name) { throw new Error(`Name already specified: ${filters.name}`); } filters.name = name; } if (!namespace) { throw new Error("Namespace must be defined"); } if (!hasLogs(kind)) { throw new Error("Kind must be Pod or have a selector"); } try { const object = await k8sExec(model, filters, { method: FetchMethods.GET }); if (kind !== "Pod") { if (kind === "Service") { const svc = object; labels = svc.spec.selector ?? {}; } else if (kind === "ReplicaSet" || kind === "Deployment" || kind === "StatefulSet" || kind === "DaemonSet") { const rs = object; labels = rs.spec.selector.matchLabels ?? {}; } const list = await K8s(Pod, { namespace: filters.namespace, labels }).Get(); list.items.forEach(item => { return podList.push(item); }); } else { podList.push(object); } } catch { throw new Error(`Failed to get logs in KFC Logs function`); } const podModel = { ...model, name: "V1Pod" }; const logPromises = podList.map(po => k8sExec(podModel, { ...filters, name: po.metadata.name }, { method: FetchMethods.LOG })); const responses = await Promise.all(logPromises); const combinedString = responses.reduce((accumulator, currentString, i) => { const prefixedLines = currentString .split("\n") .map(line => { return line !== "" ? `[pod/${podList[i].metadata.name}] ${line}` : ""; }) .filter(str => str !== ""); return [...accumulator, ...prefixedLines]; }, []); return combinedString; } /** * @inheritdoc * @see {@link K8sInit.Get} */ async function Get(name) { if (name) { if (filters.name) { throw new Error(`Name already specified: ${filters.name}`); } filters.name = name; } return k8sExec(model, filters, { method: FetchMethods.GET }); } /** * @inheritdoc * @see {@link K8sInit.Delete} */ async function Delete(filter) { if (typeof filter === "string") { filters.name = filter; } else if (filter) { syncFilters(filter); } try { // Try to delete the resource await k8sExec(model, filters, { method: FetchMethods.DELETE }); } catch (e) { // If the resource doesn't exist, ignore the error if (e.status === StatusCodes.NOT_FOUND) { return; } throw e; } } /** * @inheritdoc * @see {@link K8sInit.Apply} */ async function Apply(resource, applyCfg = { force: false }) { syncFilters(resource); return k8sExec(model, filters, { method: FetchMethods.APPLY, payload: resource }, applyCfg); } /** * @inheritdoc * @see {@link K8sInit.Create} */ async function Create(resource) { syncFilters(resource); return k8sExec(model, filters, { method: FetchMethods.POST, payload: resource }); } /** * @inheritdoc * @see {@link K8sInit.Evict} */ async function Evict(filter) { if (typeof filter === "string") { filters.name = filter; } else if (filter) { syncFilters(filter); } try { const evictionPayload = { apiVersion: "policy/v1", kind: "Eviction", metadata: { name: filters.name, namespace: filters.namespace, }, }; // Try to evict the resource await k8sExec(model, filters, { method: FetchMethods.POST, payload: evictionPayload, }); } catch (e) { // If the resource doesn't exist, ignore the error if (e.status === StatusCodes.NOT_FOUND) { return; } throw e; } } /** * @inheritdoc * @see {@link K8sInit.Patch} */ async function Patch(payload) { // If there are no operations, throw an error if (payload.length < 1) { throw new Error("No operations specified"); } return k8sExec(model, filters, { method: FetchMethods.PATCH, payload }); } /** * @inheritdoc * @see {@link K8sInit.PatchStatus} */ async function PatchStatus(resource) { syncFilters(resource); return k8sExec(model, filters, { method: FetchMethods.PATCH_STATUS, payload: resource }); } /** * @inheritdoc * @see {@link K8sInit.Watch} */ function Watch(callback, watchCfg) { return new Watcher(model, filters, callback, watchCfg); } /** * @inheritdoc * @see {@link K8sInit.Raw} */ async function Raw(url, method = FetchMethods.GET) { const thing = await k8sCfg(method); const { opts, serverUrl } = thing; const resp = await fetch(`${serverUrl}${url}`, opts); if (resp.ok) { return resp.data; } throw resp; } /** * * @param operation - The operation to perform, either "add" or "remove" * @param finalizer - The finalizer to add or remove * @param name - (optional) the name of the resource to finalize, if not provided, uses filters * @inheritdoc * @see {@link K8sInit.Finalize} */ async function Finalize(operation, finalizer, name) { if (name) { if (filters.name) { throw new Error(`Name already specified: ${filters.name}`); } filters.name = name; } // need to do a GET to get the array index of the finalizer const object = await k8sExec(model, filters, { method: FetchMethods.GET }); if (!object) { throw new Error("Resource not found"); } const finalizers = updateFinalizersOrSkip(operation, finalizer, object); if (!finalizers) return; removeControllerFields(object); await k8sExec(model, filters, { method: FetchMethods.APPLY, payload: { ...object, metadata: { ...object.metadata, finalizers, }, }, }, { force: true }); } /** * * @param replicas - the number of replicas to scale to * @param name - (optional) the name of the resource to scale, if not provided, uses filters * @inheritdoc * @see {@link K8sInit.Scale} */ async function Scale(replicas, name) { if (name) { if (filters.name) { throw new Error(`Name already specified: ${filters.name}`); } filters.name = name; } await k8sExec(model, filters, { method: FetchMethods.PATCH, payload: [{ op: "replace", path: "/spec/replicas", value: replicas }], subResourceConfig: { ScaleConfig: { replicas, }, }, }, {}); } /** * @inheritdoc * @see {@link K8sInit.Proxy} */ async function Proxy(name, port) { if (name) { if (filters.name) { throw new Error(`Name already specified: ${filters.name}`); } filters.name = name; } const object = await k8sExec(model, filters, { method: FetchMethods.GET, subResourceConfig: { ProxyConfig: { port: port || "" } }, }); return `${object}`; } return { InNamespace, Apply, Create, Patch, PatchStatus, Raw, ...withFilters }; } /** * * Remove controller fields from the Kubernetes object. * This is necessary for ensuring that the object can be applied without conflicts. * * @param object - the Kubernetes object to remove controller fields from */ export function removeControllerFields(object) { delete object.metadata?.managedFields; delete object.metadata?.resourceVersion; delete object.metadata?.uid; delete object.metadata?.creationTimestamp; delete object.metadata?.generation; delete object.metadata?.finalizers; } /** * Mutates the finalizers list based on the operation. * Throws or returns early if no update is necessary. * * @param operation - "add" or "remove" * @param finalizer - The finalizer to add/remove * @param object - The Kubernetes resource object * @returns The updated finalizers list or `null` if no update is needed */ export function updateFinalizersOrSkip(operation, finalizer, object) { const current = object.metadata?.finalizers ?? []; const isPresent = current.includes(finalizer); if ((operation === "remove" && !isPresent) || (operation === "add" && isPresent)) { return null; // no-op } switch (operation) { case "remove": return current.filter(f => f !== finalizer); case "add": return [...current, finalizer]; default: throw new Error(`Unsupported operation: ${operation}`); } }