/** * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ // Base class for all kubernetes objects import moment from "moment"; import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata } from "./kube-json-api"; import { formatDuration, hasOptionalTypedProperty, hasTypedProperty, isObject, isString, isNumber, bindPredicate, isTypedArray, isRecord } from "@k8slens/utilities"; import type { ItemObject } from "../item.store"; import type { Patch } from "rfc6902"; import assert from "assert"; import type { JsonObject } from "type-fest"; import requestKubeObjectPatchInjectable from "./endpoints/resource-applier.api/request-patch.injectable"; import { apiKubeInjectionToken } from "./api-kube"; import requestKubeObjectCreationInjectable from "./endpoints/resource-applier.api/request-update.injectable"; import { dump } from "js-yaml"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import autoBind from "auto-bind"; export type KubeJsonApiDataFor = K extends KubeObject ? KubeJsonApiData : never; export interface KubeObjectConstructorData { readonly kind?: string; readonly namespaced?: boolean; readonly apiBase?: string; } export type KubeObjectConstructor = (new (data: Data) => K) & KubeObjectConstructorData; export interface OwnerReference { apiVersion: string; kind: string; name: string; uid: string; controller?: boolean; blockOwnerDeletion?: boolean; } export type KubeTemplateObjectMetadata = Pick, "annotations" | "finalizers" | "generateName" | "labels" | "ownerReferences"> & { name?: string; namespace?: ScopedNamespace; }; export interface BaseKubeJsonApiObjectMetadata { /** * Annotations is an unstructured key value map stored with a resource that may be set by * external tools to store and retrieve arbitrary metadata. They are not queryable and should be * preserved when modifying objects. * * More info: http://kubernetes.io/docs/user-guide/annotations */ annotations?: Partial>; /** * The name of the cluster which the object belongs to. This is used to distinguish resources * with same name and namespace in different clusters. This field is not set anywhere right now * and apiserver is going to ignore it if set in create or update request. */ clusterName?: string; /** * CreationTimestamp is a timestamp representing the server time when this object was created. It * is not guaranteed to be set in happens-before order across separate operations. Clients may * not set this value. It is represented in RFC3339 form and is in UTC. Populated by the system. * * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ readonly creationTimestamp?: string; /** * Number of seconds allowed for this object to gracefully terminate before it will be removed * from the system. Only set when deletionTimestamp is also set. May only be shortened. */ readonly deletionGracePeriodSeconds?: number; /** * DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field * is set by the server when a graceful deletion is requested by the user, and is not directly * settable by a client. The resource is expected to be deleted (no longer visible from resource * lists, and not reachable by name) after the time in this field, once the finalizers list is * empty. As long as the finalizers list contains items, deletion is blocked. Once the * `deletionTimestamp` is set, this value may not be unset or be set further into the future, * although it may be shortened or the resource may be deleted prior to this time. For example, * a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a * graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet * will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the * pod from the API. In the presence of network partitions, this object may still exist after * this timestamp, until an administrator or automated process can determine the resource is * fully terminated. If not set, graceful deletion of the object has not been requested. * Populated by the system when a graceful deletion is requested. * * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ readonly deletionTimestamp?: string; /** * Must be empty before the object is deleted from the registry. Each entry is an identifier for * the responsible component that will remove the entry from the list. If the deletionTimestamp * of the object is non-nil, entries in this list can only be removed. Finalizers may be * processed and removed in any order. Order is NOT enforced because it introduces significant * risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder * it. If the finalizer list is processed in order, then this can lead to a situation in which * the component responsible for the first finalizer in the list is waiting for a signal (field * value, external system, or other) produced by a component responsible for a finalizer later in * the list, resulting in a deadlock. Without enforced ordering finalizers are free to order * amongst themselves and are not vulnerable to ordering changes in the list. */ finalizers?: string[]; /** * GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the * Name field has not been provided. If this field is used, the name returned to the client will * be different than the name passed. This value will also be combined with a unique suffix. The * provided value has the same validation rules as the Name field, and may be truncated by the * length of the suffix required to make the value unique on the server. If this field is * specified and the generated name exists, the server will NOT return a 409 - instead, it will * either return 201 Created or 500 with Reason ServerTimeout indicating a unique name could not * be found in the time allotted, and the client should retry (optionally after the time indicated * in the Retry-After header). Applied only if Name is not specified. * * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ generateName?: string; /** * A sequence number representing a specific generation of the desired state. Populated by the * system. */ readonly generation?: number; /** * Map of string keys and values that can be used to organize and categorize (scope and select) * objects. May match selectors of replication controllers and services. * * More info: http://kubernetes.io/docs/user-guide/labels */ labels?: Partial>; /** * ManagedFields maps workflow-id and version to the set of fields that are managed by that * workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set * or understand this field. A workflow can be the user's name, a controller's name, or the name * of a specific apply path like "ci-cd". The set of fields is always in the version that the * workflow used when modifying the object. */ managedFields?: unknown[]; /** * Name must be unique within a namespace. Is required when creating resources, although some * resources may allow a client to request the generation of an appropriate name automatically. * Name is primarily intended for creation idempotence and configuration definition. * * More info: http://kubernetes.io/docs/user-guide/identifiers#names */ readonly name: string; /** * Namespace defines the space within which each name must be unique. An empty namespace is * equivalent to the "default" namespace, but "default" is the canonical representation. Not all * objects are required to be scoped to a namespace - the value of this field for those objects * will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces */ readonly namespace?: ScopedNamespace; /** * List of objects depended by this object. If ALL objects in the list have been deleted, this * object will be garbage collected. If this object is managed by a controller, then an entry in * this list will point to this controller, with the controller field set to true. There cannot * be more than one managing controller. */ ownerReferences?: OwnerReference[]; /** * An opaque value that represents the internal version of this object that can be used by * clients to determine when objects have changed. May be used for optimistic concurrency, change * detection, and the watch operation on a resource or set of resources. Clients must treat these * values as opaque and passed unmodified back to the server. They may only be valid for a * particular resource or set of resources. Populated by the system. Value must be treated as * opaque by clients. * * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ readonly resourceVersion?: string; /** * SelfLink is a URL representing this object. Populated by the system. */ readonly selfLink?: string; /** * UID is the unique in time and space value for this object. It is typically generated by the * server on successful creation of a resource and is not allowed to change on PUT operations. * Populated by the system. * * More info: http://kubernetes.io/docs/user-guide/identifiers#uids */ readonly uid?: string; [key: string]: unknown; } export type KubeJsonApiObjectMetadata = BaseKubeJsonApiObjectMetadata & ( Namespaced extends KubeObjectScope.Namespace ? { readonly namespace: string } : {} ); export type KubeObjectMetadata = KubeJsonApiObjectMetadata & { readonly selfLink: string; readonly uid: string; readonly name: string; readonly resourceVersion: string; }; export type NamespaceScopedMetadata = KubeObjectMetadata; export type ClusterScopedMetadata = KubeObjectMetadata; export interface KubeStatusData { kind: string; apiVersion: string; code: number; message?: string; reason?: string; } export function isKubeStatusData(object: unknown): object is KubeStatusData { return isObject(object) && hasTypedProperty(object, "kind", isString) && hasTypedProperty(object, "apiVersion", isString) && hasTypedProperty(object, "code", isNumber) && hasOptionalTypedProperty(object, "message", isString) && hasOptionalTypedProperty(object, "reason", isString) && object.kind === "Status"; } export class KubeStatus { public readonly kind = "Status"; public readonly apiVersion: string; public readonly code: number; public readonly message: string; public readonly reason: string; constructor(data: KubeStatusData) { this.apiVersion = data.apiVersion; this.code = data.code; this.message = data.message || ""; this.reason = data.reason || ""; } } export interface BaseKubeObjectCondition { /** * Last time the condition transit from one status to another. * * @type Date */ lastTransitionTime?: string; /** * A human readable message indicating details about last transition. */ message?: string; /** * brief (usually one word) readon for the condition's last transition. */ reason?: string; /** * Status of the condition */ status: "True" | "False" | "Unknown"; /** * Type of the condition */ type: string; } export interface KubeObjectStatus { conditions?: BaseKubeObjectCondition[]; } export type KubeMetaField = keyof KubeJsonApiObjectMetadata; export class KubeCreationError extends Error { constructor(message: string, public data: any) { super(message); } } export type LabelMatchExpression = { /** * The label key that the selector applies to. */ key: string; } & ( { /** * This represents the key's relationship to a set of values. */ operator: "Exists" | "DoesNotExist"; values?: undefined; } | { operator: "In" | "NotIn"; /** * The set of values for to match according to the operator for the label. */ values: string[]; } ); export interface Toleration { key?: string; operator?: string; effect?: string; value?: string; tolerationSeconds?: number; } export interface ObjectReference { apiVersion?: string; fieldPath?: string; kind?: string; name: string; namespace?: string; resourceVersion?: string; uid?: string; } export interface LocalObjectReference { name: string; } export interface TypedLocalObjectReference { apiGroup?: string; kind: string; name: string; } export interface NodeAffinity { nodeSelectorTerms?: LabelSelector[]; weight: number; preference: LabelSelector; } export interface PodAffinity { labelSelector: LabelSelector; topologyKey: string; } export interface SpecificAffinity { requiredDuringSchedulingIgnoredDuringExecution?: T[]; preferredDuringSchedulingIgnoredDuringExecution?: T[]; } export interface Affinity { nodeAffinity?: SpecificAffinity; podAffinity?: SpecificAffinity; podAntiAffinity?: SpecificAffinity; } export interface LabelSelector { matchLabels?: Partial>; matchExpressions?: LabelMatchExpression[]; } export enum KubeObjectScope { Namespace, Cluster, } export type ScopedNamespace = ( Namespaced extends KubeObjectScope.Namespace ? string : Namespaced extends KubeObjectScope.Cluster ? undefined : string | undefined ); const resourceApplierAnnotationsForFiltering = [ "kubectl.kubernetes.io/last-applied-configuration", ]; const filterOutResourceApplierAnnotations = (label: string) => !resourceApplierAnnotationsForFiltering.some(key => label.startsWith(key)); export class KubeObject< Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = unknown, Spec = unknown, > implements ItemObject { static readonly kind?: string; static readonly namespaced?: boolean; static readonly apiBase?: string; apiVersion!: string; kind!: string; metadata!: Metadata; status?: Status; spec!: Spec; static create< Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = unknown, Spec = unknown, >(data: KubeJsonApiData) { return new KubeObject(data); } static isNonSystem(item: KubeJsonApiData | KubeObject, unknown, unknown>) { return !item.metadata.name?.startsWith("system:"); } static isJsonApiData(object: unknown): object is KubeJsonApiData { return ( isObject(object) && hasTypedProperty(object, "kind", isString) && hasTypedProperty(object, "apiVersion", isString) && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) ); } static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata { return ( isObject(object) && hasOptionalTypedProperty(object, "resourceVersion", isString) && hasOptionalTypedProperty(object, "selfLink", isString) ); } static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiObjectMetadata { return ( isObject(object) && hasTypedProperty(object, "uid", isString) && hasTypedProperty(object, "name", isString) && hasTypedProperty(object, "resourceVersion", isString) && hasOptionalTypedProperty(object, "selfLink", isString) && hasOptionalTypedProperty(object, "namespace", isString) && hasOptionalTypedProperty(object, "creationTimestamp", isString) && hasOptionalTypedProperty(object, "continue", isString) && hasOptionalTypedProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) && hasOptionalTypedProperty(object, "labels", bindPredicate(isRecord, isString, isString)) && hasOptionalTypedProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) ); } static isPartialJsonApiMetadata(object: unknown): object is Partial { return ( isObject(object) && hasOptionalTypedProperty(object, "uid", isString) && hasOptionalTypedProperty(object, "name", isString) && hasOptionalTypedProperty(object, "resourceVersion", isString) && hasOptionalTypedProperty(object, "selfLink", isString) && hasOptionalTypedProperty(object, "namespace", isString) && hasOptionalTypedProperty(object, "creationTimestamp", isString) && hasOptionalTypedProperty(object, "continue", isString) && hasOptionalTypedProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) && hasOptionalTypedProperty(object, "labels", bindPredicate(isRecord, isString, isString)) && hasOptionalTypedProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) ); } static isPartialJsonApiData(object: unknown): object is Partial { return ( isObject(object) && hasOptionalTypedProperty(object, "kind", isString) && hasOptionalTypedProperty(object, "apiVersion", isString) && hasOptionalTypedProperty(object, "metadata", KubeObject.isPartialJsonApiMetadata) ); } static isJsonApiDataList(object: unknown, verifyItem: (val: unknown) => val is T): object is KubeJsonApiDataList { return ( isObject(object) && hasTypedProperty(object, "kind", isString) && hasTypedProperty(object, "apiVersion", isString) && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata) && hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem)) ); } static stringifyLabels(labels?: Partial>): string[] { if (!labels) return []; return Object.entries(labels).map(([name, value]) => `${name}=${value}`); } /** * These must be RFC6902 compliant paths */ private static readonly nonEditablePathPrefixes = [ "/metadata/managedFields", "/status", ]; private static readonly nonEditablePaths = new Set([ "/apiVersion", "/kind", "/metadata/name", "/metadata/selfLink", "/metadata/resourceVersion", "/metadata/uid", ...KubeObject.nonEditablePathPrefixes, ]); constructor(data: KubeJsonApiData) { if (typeof data !== "object") { throw new TypeError(`Cannot create a KubeObject from ${typeof data}`); } if (!data.metadata || typeof data.metadata !== "object") { throw new KubeCreationError(`Cannot create a KubeObject from an object without metadata`, data); } Object.assign(this, data); autoBind(this); } get selfLink() { return this.metadata.selfLink; } getId() { return this.metadata.uid; } getResourceVersion() { return this.metadata.resourceVersion; } getScopedName() { const ns = this.getNs(); const res = ns ? `${ns}/` : ""; return res + this.getName(); } getName() { return this.metadata.name; } getNs(): Metadata["namespace"] { // avoid "null" serialization via JSON.stringify when post data return (this.metadata.namespace || undefined) as never; } /** * This function computes the number of milliseconds from the UNIX EPOCH to the * creation timestamp of this object. */ getCreationTimestamp() { if (!this.metadata.creationTimestamp) { return Date.now(); } return new Date(this.metadata.creationTimestamp).getTime(); } /** * @deprecated This function computes a new "now" on every call which might cause subtle issues if called multiple times * * NOTE: Generally you can use `getCreationTimestamp` instead. */ getTimeDiffFromNow(): number { if (!this.metadata.creationTimestamp) { return 0; } return Date.now() - new Date(this.metadata.creationTimestamp).getTime(); } /** * @deprecated This function computes a new "now" on every call might cause subtle issues if called multiple times * * NOTE: this function also is not reactive to updates in the current time so it should not be used for renderering */ getAge(humanize = true, compact = true, fromNow = false): string | number { if (fromNow) { return moment(this.metadata.creationTimestamp).fromNow(); // "string", getTimeDiffFromNow() cannot be used } const diff = this.getTimeDiffFromNow(); if (humanize) { return formatDuration(diff, compact); } return diff; } getFinalizers(): string[] { return this.metadata.finalizers || []; } getLabels(): string[] { return KubeObject.stringifyLabels(this.metadata.labels); } getAnnotations(filter = false): string[] { const labels = KubeObject.stringifyLabels(this.metadata.annotations); if (!filter) { return labels; } return labels.filter(filterOutResourceApplierAnnotations); } getOwnerRefs() { const refs = this.metadata.ownerReferences || []; const namespace = this.getNs(); return refs.map(ownerRef => ({ ...ownerRef, namespace })); } getSearchFields() { const { getName, getId, getNs, getAnnotations, getLabels } = this; return [ getName(), getNs(), getId(), ...getLabels(), ...getAnnotations(true), ]; } toPlainObject() { return JSON.parse(JSON.stringify(this)) as JsonObject; } /** * @deprecated use KubeApi.patch instead */ async patch(patch: Patch): Promise { for (const op of patch) { if (KubeObject.nonEditablePaths.has(op.path)) { throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`); } for (const pathPrefix of KubeObject.nonEditablePathPrefixes) { if (op.path.startsWith(`${pathPrefix}/`)) { throw new Error(`Failed to update ${this.kind}: Child JSON pointer of ${op.path} has been modified`); } } } const di = getLegacyGlobalDiForExtensionApi(); const requestKubeObjectPatch = di.inject(requestKubeObjectPatchInjectable); const result = await requestKubeObjectPatch(this.getName(), this.kind, this.getNs(), patch); if (!result.callWasSuccessful) { throw new Error(result.error); } return result.response; } /** * Perform a full update (or more specifically a replace) * * Note: this is brittle if `data` is not actually partial (but instead whole). * As fields such as `resourceVersion` will probably out of date. This is a * common race condition. * * @deprecated use KubeApi.update instead */ async update(data: Partial): Promise { const di = getLegacyGlobalDiForExtensionApi(); const requestKubeObjectCreation = di.inject(requestKubeObjectCreationInjectable); const descriptor = dump({ ...this.toPlainObject(), ...data, }); const result = await requestKubeObjectCreation(descriptor); if (!result.callWasSuccessful) { throw new Error(result.error); } return result.response; } /** * @deprecated use KubeApi.delete instead */ delete(params?: object) { assert(this.selfLink, "selfLink must be present to delete self"); const di = getLegacyGlobalDiForExtensionApi(); const apiKube = di.inject(apiKubeInjectionToken); return apiKube.del(this.selfLink, params); } }