mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
lot more stuff
This commit is contained in:
81
src/services/resources/resources.ref.ts
Normal file
81
src/services/resources/resources.ref.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
import type { Resource } from './resources.ts';
|
||||
import type { ResourceEvents } from './resources.resource.ts';
|
||||
|
||||
type ResourceReferenceEvents<T extends KubernetesObject> = ResourceEvents<T> & {
|
||||
replaced: (options: { previous: Resource<T> | undefined; next: Resource<T> | undefined }) => void;
|
||||
};
|
||||
|
||||
class ResourceReference<T extends KubernetesObject = KubernetesObject> extends EventEmitter<
|
||||
ResourceReferenceEvents<T>
|
||||
> {
|
||||
#current?: Resource<T>;
|
||||
#updatedEvent: ResourceEvents<T>['updated'];
|
||||
#changedEvent: ResourceEvents<T>['changed'];
|
||||
#changedMetadateEvent: ResourceEvents<T>['changedMetadate'];
|
||||
#changedSpecEvent: ResourceEvents<T>['changedSpec'];
|
||||
#changedStatusEvent: ResourceEvents<T>['changedStatus'];
|
||||
#deletedEvent: ResourceEvents<T>['deleted'];
|
||||
|
||||
constructor(current?: Resource<T>) {
|
||||
super();
|
||||
this.#updatedEvent = this.emit.bind(this, 'updated');
|
||||
this.#changedEvent = this.emit.bind(this, 'changed');
|
||||
this.#changedMetadateEvent = this.emit.bind(this, 'changedMetadate');
|
||||
this.#changedSpecEvent = this.emit.bind(this, 'changedSpec');
|
||||
this.#changedStatusEvent = this.emit.bind(this, 'changedStatus');
|
||||
this.#deletedEvent = this.emit.bind(this, 'deleted');
|
||||
this.current = current;
|
||||
}
|
||||
|
||||
public get current() {
|
||||
return this.#current;
|
||||
}
|
||||
|
||||
public set current(next: Resource<T> | undefined) {
|
||||
const previous = this.#current;
|
||||
if (next === previous) {
|
||||
return;
|
||||
}
|
||||
if (this.#current) {
|
||||
this.#current.off('updated', this.#updatedEvent);
|
||||
this.#current.off('changed', this.#changedEvent);
|
||||
this.#current.off('changedMetadate', this.#changedMetadateEvent);
|
||||
this.#current.off('changedSpec', this.#changedSpecEvent);
|
||||
this.#current.off('changedStatus', this.#changedStatusEvent);
|
||||
this.#current.off('deleted', this.#deletedEvent);
|
||||
}
|
||||
|
||||
if (next) {
|
||||
next.on('updated', this.#updatedEvent);
|
||||
next.on('changed', this.#changedEvent);
|
||||
next.on('changedMetadate', this.#changedMetadateEvent);
|
||||
next.on('changedSpec', this.#changedSpecEvent);
|
||||
next.on('changedStatus', this.#changedStatusEvent);
|
||||
next.on('deleted', this.#deletedEvent);
|
||||
}
|
||||
this.#current = next;
|
||||
this.emit('replaced', {
|
||||
previous,
|
||||
next,
|
||||
});
|
||||
this.emit('changedStatus', {
|
||||
previous: previous && 'status' in previous ? (previous.status as ExpectedAny) : undefined,
|
||||
next: next && 'status' in next ? (next.status as ExpectedAny) : undefined,
|
||||
});
|
||||
this.emit('changedMetadate', {
|
||||
previous: previous && 'metadata' in previous ? (previous.metadata as ExpectedAny) : undefined,
|
||||
next: next && 'metadata' in next ? (next.metadata as ExpectedAny) : undefined,
|
||||
});
|
||||
this.emit('changedSpec', {
|
||||
previous: previous && 'spec' in previous ? (previous.spec as ExpectedAny) : undefined,
|
||||
next: next && 'spec' in next ? (next.spec as ExpectedAny) : undefined,
|
||||
});
|
||||
this.emit('changed');
|
||||
this.emit('updated');
|
||||
}
|
||||
}
|
||||
|
||||
export { ResourceReference };
|
||||
289
src/services/resources/resources.resource.ts
Normal file
289
src/services/resources/resources.resource.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { ApiException, PatchStrategy, V1MicroTime, type KubernetesObject } from '@kubernetes/client-node';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import equal from 'deep-equal';
|
||||
|
||||
import { Services } from '../../utils/service.ts';
|
||||
import { K8sService } from '../k8s/k8s.ts';
|
||||
import { Queue } from '../queue/queue.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { ResourceService } from './resources.ts';
|
||||
|
||||
type ResourceOptions<T extends KubernetesObject> = {
|
||||
services: Services;
|
||||
manifest?: T;
|
||||
data: {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UnknownResource = KubernetesObject & {
|
||||
spec: ExpectedAny;
|
||||
data: ExpectedAny;
|
||||
};
|
||||
|
||||
type EventOptions = {
|
||||
reason: string;
|
||||
message: string;
|
||||
action: string;
|
||||
type: 'Normal' | 'Warning' | 'Error';
|
||||
};
|
||||
|
||||
type ResourceEvents<T extends KubernetesObject> = {
|
||||
updated: () => void;
|
||||
deleted: () => void;
|
||||
changed: () => void;
|
||||
changedStatus: (options: {
|
||||
previous: T extends { status: infer K } ? K | undefined : never;
|
||||
next: T extends { status: infer K } ? K | undefined : never;
|
||||
}) => void;
|
||||
changedMetadate: (options: { previous: T['metadata'] | undefined; next: T['metadata'] | undefined }) => void;
|
||||
changedSpec: (options: {
|
||||
previous: T extends { spec: infer K } ? K | undefined : never;
|
||||
next: T extends { spec: infer K } ? K | undefined : never;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
class Resource<T extends KubernetesObject = UnknownResource> extends EventEmitter<ResourceEvents<T>> {
|
||||
#options: ResourceOptions<T>;
|
||||
#queue: Queue;
|
||||
|
||||
constructor(options: ResourceOptions<T>) {
|
||||
super();
|
||||
this.#options = options;
|
||||
this.#queue = new Queue({ concurrency: 1 });
|
||||
}
|
||||
|
||||
public get specifier() {
|
||||
return this.#options.data;
|
||||
}
|
||||
|
||||
public get manifest() {
|
||||
return this.#options?.manifest;
|
||||
}
|
||||
|
||||
public set manifest(obj: T | undefined) {
|
||||
if (equal(obj, this.manifest)) {
|
||||
return;
|
||||
}
|
||||
this.#options.manifest = obj;
|
||||
const nextManifest = obj || {};
|
||||
const currentManifest = this.manifest || {};
|
||||
const nextStatus = 'status' in nextManifest ? nextManifest.status : undefined;
|
||||
const currentStatus = 'status' in currentManifest ? currentManifest.status : undefined;
|
||||
if (!equal(nextStatus, currentStatus)) {
|
||||
this.emit('changedStatus', {
|
||||
previous: currentStatus as ExpectedAny,
|
||||
next: nextStatus as ExpectedAny,
|
||||
});
|
||||
}
|
||||
|
||||
const nextSpec = 'spec' in nextManifest ? nextManifest.spec : undefined;
|
||||
const currentSpec = 'spec' in currentManifest ? currentManifest.spec : undefined;
|
||||
if (!equal(nextSpec, currentSpec)) {
|
||||
this.emit('changedSpec', {
|
||||
next: nextSpec as ExpectedAny,
|
||||
previous: currentSpec as ExpectedAny,
|
||||
});
|
||||
}
|
||||
|
||||
const nextMetadata = 'metadata' in nextManifest ? nextManifest.metadata : undefined;
|
||||
const currentMetadata = 'metadata' in currentManifest ? currentManifest.metadata : undefined;
|
||||
if (!equal(nextMetadata, currentMetadata)) {
|
||||
this.emit('changedMetadate', {
|
||||
next: nextMetadata as ExpectedAny,
|
||||
previous: currentMetadata as ExpectedAny,
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('updated');
|
||||
this.emit('changed');
|
||||
}
|
||||
|
||||
public get ref() {
|
||||
if (!this.metadata?.uid) {
|
||||
throw new Error('No uid for resource');
|
||||
}
|
||||
return {
|
||||
apiVersion: this.apiVersion,
|
||||
kind: this.kind,
|
||||
name: this.name,
|
||||
uid: this.metadata.uid,
|
||||
};
|
||||
}
|
||||
|
||||
public get exists() {
|
||||
return !!this.manifest;
|
||||
}
|
||||
|
||||
public get apiVersion() {
|
||||
return this.#options.data.apiVersion;
|
||||
}
|
||||
|
||||
public get group() {
|
||||
const [group] = this.apiVersion?.split('/') || [];
|
||||
return group;
|
||||
}
|
||||
|
||||
public get version() {
|
||||
const [, version] = this.apiVersion?.split('/') || [];
|
||||
return version;
|
||||
}
|
||||
|
||||
public get kind() {
|
||||
return this.#options.data.kind;
|
||||
}
|
||||
|
||||
public get metadata() {
|
||||
return this.manifest?.metadata;
|
||||
}
|
||||
|
||||
public get name() {
|
||||
return this.#options.data.name;
|
||||
}
|
||||
|
||||
public get namespace() {
|
||||
return this.#options.data.namespace;
|
||||
}
|
||||
|
||||
public get spec(): T extends { spec?: infer K } ? K | undefined : never {
|
||||
if (this.manifest && 'spec' in this.manifest) {
|
||||
return this.manifest.spec as ExpectedAny;
|
||||
}
|
||||
return undefined as ExpectedAny;
|
||||
}
|
||||
|
||||
public get data(): T extends { data?: infer K } ? K | undefined : never {
|
||||
if (this.manifest && 'data' in this.manifest) {
|
||||
return this.manifest.data as ExpectedAny;
|
||||
}
|
||||
return undefined as ExpectedAny;
|
||||
}
|
||||
|
||||
public get owners() {
|
||||
const { services } = this.#options;
|
||||
const references = this.metadata?.ownerReferences || [];
|
||||
const resourceService = services.get(ResourceService);
|
||||
return references.map((ref) =>
|
||||
resourceService.get({
|
||||
apiVersion: ref.apiVersion,
|
||||
kind: ref.kind,
|
||||
name: ref.name,
|
||||
namespace: this.namespace,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public patch = (patch: T) =>
|
||||
this.#queue.add(async () => {
|
||||
const { services } = this.#options;
|
||||
const k8s = services.get(K8sService);
|
||||
const body = {
|
||||
...patch,
|
||||
apiVersion: this.apiVersion,
|
||||
kind: this.kind,
|
||||
metadata: {
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
...patch.metadata,
|
||||
},
|
||||
};
|
||||
try {
|
||||
this.manifest = await k8s.objectsApi.patch(
|
||||
body,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
PatchStrategy.MergePatch,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiException && err.code === 404) {
|
||||
this.manifest = await k8s.objectsApi.create(body);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
public delete = () =>
|
||||
this.#queue.add(async () => {
|
||||
try {
|
||||
const { services } = this.#options;
|
||||
const k8s = services.get(K8sService);
|
||||
await k8s.objectsApi.delete({
|
||||
apiVersion: this.apiVersion,
|
||||
kind: this.kind,
|
||||
metadata: {
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
});
|
||||
this.manifest = undefined;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiException && err.code === 404) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
public load = () =>
|
||||
this.#queue.add(async () => {
|
||||
const { services } = this.#options;
|
||||
const k8s = services.get(K8sService);
|
||||
try {
|
||||
const manifest = await k8s.objectsApi.read({
|
||||
apiVersion: this.apiVersion,
|
||||
kind: this.kind,
|
||||
metadata: {
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
});
|
||||
this.manifest = manifest as T;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiException && err.code === 404) {
|
||||
this.manifest = undefined;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public addEvent = async (event: EventOptions) => {
|
||||
const { services } = this.#options;
|
||||
const k8sService = services.get(K8sService);
|
||||
|
||||
await k8sService.eventsApi.createNamespacedEvent({
|
||||
namespace: this.namespace || 'default',
|
||||
body: {
|
||||
kind: 'Event',
|
||||
metadata: {
|
||||
name: `${this.name}-${Date.now()}-${Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString('hex')}`,
|
||||
namespace: this.namespace,
|
||||
},
|
||||
eventTime: new V1MicroTime(),
|
||||
note: event.message,
|
||||
action: event.action,
|
||||
reason: event.reason,
|
||||
type: event.type,
|
||||
reportingController: GROUP,
|
||||
reportingInstance: this.name,
|
||||
regarding: {
|
||||
apiVersion: this.apiVersion,
|
||||
resourceVersion: this.metadata?.resourceVersion,
|
||||
kind: this.kind,
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
uid: this.metadata?.uid,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { Resource, type UnknownResource, type ResourceEvents };
|
||||
44
src/services/resources/resources.ts
Normal file
44
src/services/resources/resources.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||
|
||||
import type { Services } from '../../utils/service.ts';
|
||||
|
||||
import { Resource } from './resources.resource.ts';
|
||||
|
||||
type ResourceGetOptions = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
class ResourceService {
|
||||
#cache: Resource<ExpectedAny>[] = [];
|
||||
#services: Services;
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#services = services;
|
||||
}
|
||||
|
||||
public get = <T extends KubernetesObject>(options: ResourceGetOptions) => {
|
||||
const { apiVersion, kind, name, namespace } = options;
|
||||
let resource = this.#cache.find(
|
||||
(resource) =>
|
||||
resource.specifier.kind === kind &&
|
||||
resource.specifier.apiVersion === apiVersion &&
|
||||
resource.specifier.name === name &&
|
||||
resource.specifier.namespace === namespace,
|
||||
);
|
||||
if (resource) {
|
||||
return resource as Resource<T>;
|
||||
}
|
||||
resource = new Resource({
|
||||
data: options,
|
||||
services: this.#services,
|
||||
});
|
||||
this.#cache.push(resource);
|
||||
return resource as Resource<T>;
|
||||
};
|
||||
}
|
||||
|
||||
export { ResourceReference } from './resources.ref.ts';
|
||||
export { ResourceService, Resource };
|
||||
Reference in New Issue
Block a user