This commit is contained in:
Morten Olsen
2025-10-23 20:31:15 +02:00
parent b851dc3006
commit 78995406ca
46 changed files with 707 additions and 119 deletions

View File

@@ -1,9 +1,39 @@
import { KubeConfig } from '@kubernetes/client-node';
import { ApiextensionsV1Api, CustomObjectsApi, KubeConfig, KubernetesObjectApi } from '@kubernetes/client-node';
class K8sConfig {
#config: KubeConfig;
#objectsApi?: KubernetesObjectApi;
#customObjectsApi?: CustomObjectsApi;
#extensionsApi?: ApiextensionsV1Api;
class K8sConfig extends KubeConfig {
constructor() {
super();
this.loadFromDefault();
this.#config = new KubeConfig();
this.#config.loadFromDefault();
}
public get kubeConfig() {
return this.#config;
}
public get objectsApi() {
if (!this.#objectsApi) {
this.#objectsApi = this.#config.makeApiClient(KubernetesObjectApi)
}
return this.#objectsApi;
}
public get customObjectsApi() {
if (!this.#customObjectsApi) {
this.#customObjectsApi = this.#config.makeApiClient(CustomObjectsApi);
}
return this.#customObjectsApi;
}
public get extensionsApi() {
if (!this.#extensionsApi) {
this.#extensionsApi = this.#config.makeApiClient(ApiextensionsV1Api)
}
return this.#extensionsApi;
}
}

View File

@@ -1,6 +1,6 @@
import { Resource } from '../resources/resource/resource.js';
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
import { Resource } from '../resources/resource/resource.js';
class CRD extends Resource<V1CustomResourceDefinition> {
public static readonly apiVersion = 'apiextensions.k8s.io/v1';

View File

@@ -1,6 +1,7 @@
import { Resource } from '../resources/resource/resource.js';
import type { V1Deployment } from '@kubernetes/client-node';
import { Resource } from '../resources/resource/resource.js';
class Deployment extends Resource<V1Deployment> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'Deployment';

View File

@@ -1,6 +1,7 @@
import { Resource } from '../resources/resource/resource.js';
import type { V1Namespace } from '@kubernetes/client-node';
import { Resource } from '../resources/resource/resource.js';
class Namespace extends Resource<V1Namespace> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Namespace';

View File

@@ -1,6 +1,7 @@
import { Resource } from '../resources/resource/resource.js';
import type { V1PersistentVolume } from '@kubernetes/client-node';
import { Resource } from '../resources/resource/resource.js';
class PersistentVolume extends Resource<V1PersistentVolume> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'PersistentVolume';

View File

@@ -1,23 +1,19 @@
import { Resource, type ResourceOptions } from '../resources/resource/resource.js';
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
import { decodeSecret, encodeSecret } from '../utils/utils.secrets.js';
import { decodeSecret, encodeSecret } from '../utils/utils.secrets.js';
import { Resource } from '../exports.js';
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
class Secret extends Resource<V1Secret> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Secret';
constructor(options: ResourceOptions<V1Secret>) {
super(options);
}
public get value() {
return decodeSecret(this.data) as T | undefined;
return decodeSecret(this.data);
}
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
public set = async (options: SetOptions<Record<string, string>>, data?: KubernetesObject) => {
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
await this.ensure({
...data,

View File

@@ -1,6 +1,7 @@
import { Resource } from '../resources/resource/resource.js';
import type { V1Service } from '@kubernetes/client-node';
import { Resource } from '../resources/resource/resource.js';
class Service extends Resource<V1Service> {
public static readonly apiVersion = 'v1';
public static readonly kind = 'Service';

View File

@@ -1,6 +1,7 @@
import { Resource } from '../resources/resource/resource.js';
import type { V1StatefulSet } from '@kubernetes/client-node';
import { Resource } from '../resources/resource/resource.js';
class StatefulSet extends Resource<V1StatefulSet> {
public static readonly apiVersion = 'apps/v1';
public static readonly kind = 'StatefulSet';

View File

@@ -1,6 +1,7 @@
import { Resource } from '../resources/resource/resource.js';
import type { V1StorageClass } from '@kubernetes/client-node';
import { Resource } from '../resources/resource/resource.js';
class StorageClass extends Resource<V1StorageClass> {
public static readonly apiVersion = 'storage.k8s.io/v1';
public static readonly kind = 'StorageClass';

View File

@@ -1,19 +1,10 @@
import { CRD } from "./core.crd.js";
import { Deployment } from "./core.deployment.js";
import { Namespace } from "./core.namespace.js";
import { PersistentVolume } from "./core.pv.js";
import { Secret } from "./core.secret.js";
import { Service } from "./core.service.js";
import { StatefulSet } from "./core.stateful-set.js";
import { StorageClass } from "./core.storage-class.js";
import { CRD } from './core.crd.js';
import { Deployment } from './core.deployment.js';
import { Namespace } from './core.namespace.js';
import { PersistentVolume } from './core.pv.js';
import { Secret } from './core.secret.js';
import { Service } from './core.service.js';
import { StatefulSet } from './core.stateful-set.js';
import { StorageClass } from './core.storage-class.js';
export {
CRD,
Deployment,
Namespace,
PersistentVolume,
Secret,
Service,
StatefulSet,
StorageClass,
}
export { CRD, Deployment, Namespace, PersistentVolume, Secret, Service, StatefulSet, StorageClass };

View File

@@ -1,4 +1,5 @@
import { Services } from '@morten-olsen/box-utils/services';
import { ResourceService } from './resources/resources.js';
class K8sOperator {

View File

@@ -1,12 +1,14 @@
import { z, type ZodType } from 'zod';
import { CustomObjectsApi, PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
import { PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
import { CronJob, CronTime } from 'cron';
import { K8sConfig } from '../../config/config.js';
import { CoalescingQueue } from '@morten-olsen/box-utils/coalescing-queue';
import { FINALIZER } from '@morten-olsen/box-utils/consts';
import { NotReadyError } from '../../errors/errors.js';
import { Resource, type ResourceOptions } from './resource.js';
import { NotReadyError } from '../../errors/errors.js'
import { K8sConfig } from '../../config/config.js';
import type { ResourceClass } from '../resources.service.js';
const customResourceStatusSchema = z.object({
observedGeneration: z.number().optional(),
@@ -35,7 +37,7 @@ class CustomResource<TSpec extends ZodType> extends Resource<
public static readonly apiVersion: string;
public static readonly status = customResourceStatusSchema;
public static readonly labels: Record<string, string> = {};
public static readonly dependsOn?: Resource<KubernetesObject>[];
public static readonly dependsOn?: ResourceClass<ExplicitAny>[];
#reconcileQueue: CoalescingQueue<void>;
#cron: CronJob;
@@ -45,9 +47,31 @@ class CustomResource<TSpec extends ZodType> extends Resource<
this.#reconcileQueue = new CoalescingQueue({
action: async () => {
try {
if (!this.exists || this.manifest?.metadata?.deletionTimestamp) {
if (!this.exists) {
return;
}
// TODO: Read FINALIZER
// const finalizers = this.metadata?.finalizers || [];
if (this.manifest?.metadata?.deletionTimestamp) {
await this.destroy?.();
// if (this.metadata?.finalizers?.includes(FINALIZER)) {
// await this.patch({
// metadata: {
// finalizers: finalizers.filter((f) => f !== FINALIZER),
// deletionTimestamp: this.metadata?.deletionTimestamp,
// },
// } as any)
// }
return;
}
// if (this.destroy && !finalizers.includes(FINALIZER)) {
// return await this.patch({
// metadata: {
// finalizers: [...finalizers, FINALIZER]
// },
// spec: this.spec!,
// });
// }
await this.markSeen();
await this.reconcile?.();
await this.markReady();
@@ -111,6 +135,8 @@ class CustomResource<TSpec extends ZodType> extends Resource<
};
public reconcile?: () => Promise<void>;
public destroy?: () => Promise<void>;
public queueReconcile = () => {
return this.#reconcileQueue.run();
};
@@ -151,7 +177,7 @@ class CustomResource<TSpec extends ZodType> extends Resource<
public patchStatus = (status: Partial<z.infer<typeof customResourceStatusSchema>>) =>
this.queue.add(async () => {
const config = this.services.get(K8sConfig);
const customObjectsApi = config.makeApiClient(CustomObjectsApi);
const customObjectsApi = config.customObjectsApi;
if (this.scope === 'Cluster') {
await customObjectsApi.patchClusterCustomObjectStatus(
{
@@ -180,4 +206,3 @@ class CustomResource<TSpec extends ZodType> extends Resource<
}
export { CustomResource, type CustomResourceOptions };

View File

@@ -1,4 +1,5 @@
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
import type { ResourceClass } from '../resources.js';
import type { ResourceEvents } from './resource.js';
@@ -19,6 +20,9 @@ class ResourceReference<T extends ResourceClass<ExplicitAny>> extends EventEmitt
}
public set current(value: InstanceType<T> | undefined) {
if (value === this.#current?.instance) {
return;
}
const previous = this.#current;
this.#current?.unsubscribe();
if (value) {

View File

@@ -1,11 +1,11 @@
import { ApiException, KubernetesObjectApi, PatchStrategy, type KubernetesObject } from '@kubernetes/client-node';
import deepEqual from 'deep-equal';
import { ResourceService } from '../resources.service.js';
import type { Services } from '@morten-olsen/box-utils/services';
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
import { Queue } from '@morten-olsen/box-utils/queue';
import { isDeepSubset } from '@morten-olsen/box-utils/objects';
import { ResourceService } from '../resources.service.js';
import { K8sConfig } from '../../config/config.js';
type ResourceSelector = {
@@ -41,7 +41,7 @@ class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents<T
return this.#queue;
}
public get services() {
public get services(): Services {
return this.#options.services;
}
@@ -144,7 +144,7 @@ class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents<T
this.#queue.add(async () => {
const { services } = this.#options;
const config = services.get(K8sConfig);
const objectsApi = config.makeApiClient(KubernetesObjectApi);
const { objectsApi } = config;
const body = {
...patch,
apiVersion: this.selector.apiVersion,
@@ -190,4 +190,3 @@ class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents<T
}
export { Resource, type ResourceOptions, type ResourceEvents };

View File

@@ -1,16 +1,17 @@
import { ApiException, ApiextensionsV1Api, type KubernetesObject } from '@kubernetes/client-node';
import type { ZodType } from 'zod';
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
import type { Services } from '@morten-olsen/box-utils/services';
import { WatcherService } from '../watchers/watchers.js';
import { K8sConfig } from '../config/config.js';
import { createManifest } from './resources.utils.js';
import { Resource, type ResourceOptions } from './resource/resource.js';
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
import type { Services } from '@morten-olsen/box-utils/services';
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => InstanceType<typeof Resource<T>>) & {
type ResourceClass<T extends KubernetesObject> = (new (
options: ResourceOptions<T>,
) => InstanceType<typeof Resource<T>>) & {
apiVersion: string;
kind: string;
plural?: string;
@@ -47,13 +48,17 @@ class ResourceService extends EventEmitter<ResourceServiceEvents> {
public register = async (...resources: ResourceClass<ExplicitAny>[]) => {
for (const resource of resources) {
if (!this.#registry.has(resource)) {
this.#registry.set(resource, {
apiVersion: resource.apiVersion,
kind: resource.kind,
plural: resource.plural,
resources: [],
});
if (this.#registry.has(resource)) {
return;
}
this.#registry.set(resource, {
apiVersion: resource.apiVersion,
kind: resource.kind,
plural: resource.plural,
resources: [],
});
if ('dependsOn' in resource && Array.isArray(resource.dependsOn)) {
await this.register(...resource.dependsOn as ResourceClass<ExplicitAny>[]);
}
const watcherService = this.#services.get(WatcherService);
const watcher = watcherService.create({
@@ -65,7 +70,7 @@ class ResourceService extends EventEmitter<ResourceServiceEvents> {
if (!name) {
return;
}
const current = this.get(resource, name, namespace);
const current = this.#get(resource, name, namespace, manifest);
current.manifest = manifest;
});
await watcher.start();
@@ -76,7 +81,7 @@ class ResourceService extends EventEmitter<ResourceServiceEvents> {
return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType<T>[]) || [];
};
public get = <T extends ResourceClass<ExplicitAny>>(type: T, name: string, namespace?: string) => {
#get = <T extends ResourceClass<ExplicitAny>>(type: T, name: string, namespace?: string, manifest?: unknown) => {
let resourceRegistry = this.#registry.get(type);
if (!resourceRegistry) {
resourceRegistry = {
@@ -98,6 +103,7 @@ class ResourceService extends EventEmitter<ResourceServiceEvents> {
namespace,
},
services: this.#services,
manifest,
});
current.on('changed', this.emit.bind(this, 'changed', current));
resources.push(current);
@@ -105,9 +111,13 @@ class ResourceService extends EventEmitter<ResourceServiceEvents> {
return current as InstanceType<T>;
};
public get = <T extends ResourceClass<ExplicitAny>>(type: T, name: string, namespace?: string) => {
return this.#get(type, name, namespace);
};
public install = async (...resources: InstallableResourceClass<ExplicitAny>[]) => {
const config = this.#services.get(K8sConfig);
const extensionsApi = config.makeApiClient(ApiextensionsV1Api);
const { extensionsApi } = config;
for (const resource of resources) {
try {
const manifest = createManifest(resource);
@@ -136,4 +146,3 @@ class ResourceService extends EventEmitter<ResourceServiceEvents> {
}
export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass };

View File

@@ -1,4 +1,9 @@
export { CustomResource, type CustomResourceOptions } from './resource/resource.custom.js';
export { ResourceReference } from './resource/resource.reference.js';
export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass } from './resources.service.js';
export {
ResourceService,
Resource,
type ResourceOptions,
type ResourceClass,
type InstallableResourceClass,
} from './resources.service.js';

View File

@@ -52,4 +52,3 @@ const createManifest = (defintion: InstallableResourceClass<ExplicitAny>) => {
};
export { createManifest };

View File

@@ -1,6 +1,7 @@
import { KubernetesObjectApi, makeInformer, type Informer, type KubernetesObject } from '@kubernetes/client-node';
import { EventEmitter } from '@morten-olsen/box-utils/event-emitter';
import type { Services } from '@morten-olsen/box-utils/services';
import { K8sConfig } from '../../config/config.js';
type ResourceChangedAction = 'add' | 'update' | 'delete';
@@ -32,11 +33,10 @@ class Watcher<T extends KubernetesObject> extends EventEmitter<WatcherEvents<T>>
const { services, apiVersion, kind, selector } = this.#options;
const plural = this.#options.plural ?? kind.toLowerCase() + 's';
const [version, group] = apiVersion.split('/').toReversed();
const config = services.get(K8sConfig);
const { kubeConfig, objectsApi } = services.get(K8sConfig);
const path = group ? `/apis/${group}/${version}/${plural}` : `/api/${version}/${plural}`;
const objectsApi = config.makeApiClient(KubernetesObjectApi);
const informer = makeInformer<T>(
config,
kubeConfig,
path,
async () => {
return objectsApi.list(apiVersion, kind);
@@ -67,4 +67,3 @@ class Watcher<T extends KubernetesObject> extends EventEmitter<WatcherEvents<T>>
}
export { Watcher, type WatcherOptions, type ResourceChangedAction };

View File

@@ -1,5 +1,6 @@
import { Services, destroy } from "@morten-olsen/box-utils/services";
import { Watcher, type WatcherOptions } from "./watcher/watcher.js";
import { Services, destroy } from '@morten-olsen/box-utils/services';
import { Watcher, type WatcherOptions } from './watcher/watcher.js';
class WatcherService {
#services: Services;

View File

@@ -0,0 +1 @@
{"root":["./src/exports.ts","./src/global.d.ts","./src/operator.ts","./src/config/config.ts","./src/core/core.crd.ts","./src/core/core.deployment.ts","./src/core/core.namespace.ts","./src/core/core.pv.ts","./src/core/core.secret.ts","./src/core/core.service.ts","./src/core/core.stateful-set.ts","./src/core/core.storage-class.ts","./src/core/core.ts","./src/errors/errors.ts","./src/resources/resources.service.ts","./src/resources/resources.ts","./src/resources/resources.utils.ts","./src/resources/resource/resource.custom.ts","./src/resources/resource/resource.reference.ts","./src/resources/resource/resource.ts","./src/utils/utils.secrets.ts","./src/watchers/watchers.ts","./src/watchers/watcher/watcher.ts"],"version":"5.9.3"}