mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
init
This commit is contained in:
101
src/custom-resource/custom-resource.base.ts
Normal file
101
src/custom-resource/custom-resource.base.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { type Static, type TSchema } from "@sinclair/typebox";
|
||||
import { GROUP } from "../utils/consts.ts";
|
||||
import type { Services } from "../utils/service.ts";
|
||||
import { statusSchema } from "./custom-resource.status.ts";
|
||||
import type { CustomResourceRequest } from "./custom-resource.request.ts";
|
||||
|
||||
|
||||
type CustomResourceHandlerOptions<TSpec extends TSchema> = {
|
||||
request: CustomResourceRequest<TSpec>;
|
||||
services: Services;
|
||||
}
|
||||
|
||||
type CustomResourceConstructor<TSpec extends TSchema> = {
|
||||
kind: string;
|
||||
spec: TSpec;
|
||||
names: {
|
||||
plural: string;
|
||||
singular: string;
|
||||
};
|
||||
}
|
||||
|
||||
abstract class CustomResource<
|
||||
TSpec extends TSchema
|
||||
> {
|
||||
#options: CustomResourceConstructor<TSpec>;
|
||||
|
||||
constructor(options: CustomResourceConstructor<TSpec>) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
public get name() {
|
||||
return `${this.#options.names.plural}.${this.group}`;
|
||||
}
|
||||
|
||||
public get version() {
|
||||
return 'v1';
|
||||
}
|
||||
|
||||
public get group() {
|
||||
return GROUP;
|
||||
}
|
||||
|
||||
public get path() {
|
||||
return `/apis/${this.group}/v1/${this.#options.names.plural}`;
|
||||
}
|
||||
|
||||
public get kind() {
|
||||
return this.#options.kind;
|
||||
}
|
||||
|
||||
public get spec() {
|
||||
return this.#options.spec;
|
||||
}
|
||||
|
||||
public get names() {
|
||||
return this.#options.names;
|
||||
}
|
||||
|
||||
public abstract update(options: CustomResourceHandlerOptions<TSpec>): Promise<void>;
|
||||
public create?(options: CustomResourceHandlerOptions<TSpec>): Promise<void>;
|
||||
public delete?(options: CustomResourceHandlerOptions<TSpec>): Promise<void>;
|
||||
|
||||
public toManifest = () => {
|
||||
return {
|
||||
apiVersion: 'apiextensions.k8s.io/v1',
|
||||
kind: 'CustomResourceDefinition',
|
||||
metadata: {
|
||||
name: this.name,
|
||||
},
|
||||
spec: {
|
||||
group: this.group,
|
||||
names: {
|
||||
kind: this.kind,
|
||||
plural: this.#options.names.plural,
|
||||
singular: this.#options.names.singular,
|
||||
},
|
||||
scope: 'Namespaced',
|
||||
versions: [{
|
||||
name: this.version,
|
||||
served: true,
|
||||
storage: true,
|
||||
schema: {
|
||||
openAPIV3Schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: this.spec,
|
||||
status: statusSchema as any,
|
||||
}
|
||||
}
|
||||
},
|
||||
subresources: {
|
||||
status: {}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export { CustomResource, type CustomResourceConstructor, type CustomResourceHandlerOptions };
|
||||
113
src/custom-resource/custom-resource.registry.ts
Normal file
113
src/custom-resource/custom-resource.registry.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ApiException, Watch } from "@kubernetes/client-node";
|
||||
import { K8sService } from "../services/k8s.ts";
|
||||
import type { Services } from "../utils/service.ts";
|
||||
import { type CustomResource } from "./custom-resource.base.ts";
|
||||
import { CustomResourceRequest } from "./custom-resource.request.ts";
|
||||
|
||||
class CustomResourceRegistry {
|
||||
#services: Services;
|
||||
#resources: Set<CustomResource<any>> = new Set();
|
||||
#watchers: Map<string, AbortController> = new Map();
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#services = services;
|
||||
}
|
||||
|
||||
public get kinds() {
|
||||
return Array.from(this.#resources).map((r) => r.kind);
|
||||
}
|
||||
|
||||
public getByKind = (kind: string) => {
|
||||
return Array.from(this.#resources).find((r) => r.kind === kind);
|
||||
}
|
||||
|
||||
public register = (resource: CustomResource<any>) => {
|
||||
this.#resources.add(resource);
|
||||
}
|
||||
|
||||
public unregister = (resource: CustomResource<any>) => {
|
||||
this.#resources.delete(resource);
|
||||
this.#watchers.forEach((controller, kind) => {
|
||||
if (kind === resource.kind) {
|
||||
controller.abort();
|
||||
this.#watchers.delete(kind);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public watch = async () => {
|
||||
const k8sService = this.#services.get(K8sService);
|
||||
const watcher = new Watch(k8sService.config);
|
||||
for (const resource of this.#resources) {
|
||||
if (this.#watchers.has(resource.kind)) {
|
||||
continue;
|
||||
}
|
||||
const path = resource.path;
|
||||
const controller = await watcher.watch(path, {}, this.#onResourceEvent, this.#onError);
|
||||
this.#watchers.set(resource.kind, controller);
|
||||
}
|
||||
}
|
||||
|
||||
#onResourceEvent = async (type: string, obj: any) => {
|
||||
console.log(type, this.kinds);
|
||||
const { kind } = obj;
|
||||
const crd = this.getByKind(kind);
|
||||
if (!crd) {
|
||||
return;
|
||||
}
|
||||
|
||||
let handler = type === 'DELETED' ? crd.delete : crd.update;
|
||||
const request = new CustomResourceRequest({
|
||||
type: type as 'ADDED' | 'DELETED' | 'MODIFIED',
|
||||
manifest: obj,
|
||||
services: this.#services,
|
||||
});
|
||||
|
||||
const status = await request.getStatus();
|
||||
if (status.observedGeneration === obj.metadata.generation) {
|
||||
this.#services.log.debug('Skipping resource update', {
|
||||
observedGeneration: status.observedGeneration,
|
||||
generation: obj.metadata.generation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'ADDED' && crd.create) {
|
||||
handler = crd.create;
|
||||
}
|
||||
|
||||
await handler?.({
|
||||
request,
|
||||
services: this.#services,
|
||||
})
|
||||
}
|
||||
|
||||
#onError = (error: any) => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
public install = async (replace = false) => {
|
||||
const k8sService = this.#services.get(K8sService);
|
||||
for (const crd of this.#resources) {
|
||||
const manifest = crd.toManifest();
|
||||
try {
|
||||
await k8sService.extensionsApi.createCustomResourceDefinition({
|
||||
body: manifest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiException && error.code === 409) {
|
||||
if (replace) {
|
||||
await k8sService.extensionsApi.patchCustomResourceDefinition({
|
||||
name: crd.name,
|
||||
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { CustomResourceRegistry };
|
||||
147
src/custom-resource/custom-resource.request.ts
Normal file
147
src/custom-resource/custom-resource.request.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type { Services } from "../utils/service.ts";
|
||||
import { K8sService } from "../services/k8s.ts";
|
||||
import { CustomResourceRegistry } from "./custom-resource.registry.ts";
|
||||
import { CustomResourceStatus, type CustomResourceStatusType } from "./custom-resource.status.ts";
|
||||
import { ApiException, PatchStrategy, setHeaderOptions } from "@kubernetes/client-node";
|
||||
|
||||
type CustomResourceRequestOptions = {
|
||||
type: 'ADDED' | 'DELETED' | 'MODIFIED';
|
||||
manifest: any;
|
||||
services: Services;
|
||||
};
|
||||
|
||||
type CustomResourceRequestMetadata = Record<string, string> & {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
uid: string;
|
||||
resourceVersion: string;
|
||||
creationTimestamp: string;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
class CustomResourceRequest<TSpec extends TSchema>{
|
||||
#options: CustomResourceRequestOptions;
|
||||
|
||||
constructor(options: CustomResourceRequestOptions) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
public get services(): Services {
|
||||
return this.#options.services;
|
||||
}
|
||||
|
||||
public get type(): 'ADDED' | 'DELETED' | 'MODIFIED' {
|
||||
return this.#options.type;
|
||||
}
|
||||
|
||||
public get manifest() {
|
||||
return this.#options.manifest;
|
||||
}
|
||||
|
||||
public get kind(): string {
|
||||
return this.#options.manifest.kind;
|
||||
}
|
||||
|
||||
public get apiVersion(): string {
|
||||
return this.#options.manifest.apiVersion;
|
||||
}
|
||||
|
||||
public get spec(): Static<TSpec> {
|
||||
return this.#options.manifest.spec;
|
||||
}
|
||||
|
||||
public get metadata(): CustomResourceRequestMetadata {
|
||||
return this.#options.manifest.metadata;
|
||||
}
|
||||
|
||||
public isOwnerOf = (manifest: any) => {
|
||||
const ownerRef = manifest?.metadata?.ownerReferences || [];
|
||||
return ownerRef.some((ref: any) =>
|
||||
ref.apiVersion === this.apiVersion &&
|
||||
ref.kind === this.kind &&
|
||||
ref.name === this.metadata.name &&
|
||||
ref.uid === this.metadata.uid
|
||||
);
|
||||
}
|
||||
|
||||
public setStatus = async (status: CustomResourceStatusType) => {
|
||||
const { manifest, services } = this.#options;
|
||||
const { kind, metadata } = manifest;
|
||||
const registry = services.get(CustomResourceRegistry);
|
||||
const crd = registry.getByKind(kind);
|
||||
if (!crd) {
|
||||
throw new Error(`Custom resource ${kind} not found`);
|
||||
}
|
||||
|
||||
const k8sService = services.get(K8sService);
|
||||
|
||||
const { namespace = 'default', name } = metadata;
|
||||
|
||||
const response = await k8sService.customObjectsApi.patchNamespacedCustomObjectStatus({
|
||||
group: crd.group,
|
||||
version: crd.version,
|
||||
namespace,
|
||||
plural: crd.names.plural,
|
||||
name,
|
||||
body: { status },
|
||||
fieldValidation: 'Strict',
|
||||
}, setHeaderOptions('Content-Type', PatchStrategy.MergePatch))
|
||||
return response;
|
||||
}
|
||||
|
||||
public getCurrent = async () => {
|
||||
const { manifest, services } = this.#options;
|
||||
const k8sService = services.get(K8sService);
|
||||
const registry = services.get(CustomResourceRegistry);
|
||||
const crd = registry.getByKind(manifest.kind);
|
||||
if (!crd) {
|
||||
throw new Error(`Custom resource ${manifest.kind} not found`);
|
||||
}
|
||||
try {
|
||||
const resource = await k8sService.customObjectsApi.getNamespacedCustomObject({
|
||||
group: crd.group,
|
||||
version: crd.version,
|
||||
plural: crd.names.plural,
|
||||
namespace: manifest.metadata.namespace,
|
||||
name: manifest.metadata.name,
|
||||
});
|
||||
return resource as {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: CustomResourceRequestMetadata;
|
||||
spec: Static<TSpec>;
|
||||
status: CustomResourceStatusType;
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApiException && error.code === 404) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public getStatus = async () => {
|
||||
const resource = await this.getCurrent()
|
||||
if (!resource || !resource.status) {
|
||||
return new CustomResourceStatus({
|
||||
status: {
|
||||
observedGeneration: 0,
|
||||
conditions: [],
|
||||
},
|
||||
generation: 0,
|
||||
save: this.setStatus,
|
||||
});
|
||||
}
|
||||
return new CustomResourceStatus({
|
||||
status: { ...resource.status, observedGeneration: resource.status.observedGeneration },
|
||||
generation: resource.metadata.generation,
|
||||
save: this.setStatus,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { CustomResourceRequest };
|
||||
80
src/custom-resource/custom-resource.status.ts
Normal file
80
src/custom-resource/custom-resource.status.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
type CustomResourceStatusType= Static<typeof statusSchema>;
|
||||
|
||||
const statusSchema = Type.Object({
|
||||
observedGeneration: Type.Number(),
|
||||
conditions: Type.Array(Type.Object({
|
||||
type: Type.String(),
|
||||
status: Type.String({
|
||||
enum: ['True', 'False', 'Unknown']
|
||||
}),
|
||||
lastTransitionTime: Type.String(),
|
||||
reason: Type.String(),
|
||||
message: Type.String(),
|
||||
})),
|
||||
});
|
||||
|
||||
type CustomResourceStatusOptions = {
|
||||
status?: CustomResourceStatusType;
|
||||
generation: number;
|
||||
save: (status: CustomResourceStatusType) => Promise<void>;
|
||||
}
|
||||
|
||||
class CustomResourceStatus {
|
||||
#status: CustomResourceStatusType;
|
||||
#generation: number;
|
||||
#save: (status: CustomResourceStatusType) => Promise<void>;
|
||||
|
||||
constructor(options: CustomResourceStatusOptions) {
|
||||
this.#save = options.save;
|
||||
this.#status = {
|
||||
observedGeneration: options.status?.observedGeneration ?? 0,
|
||||
conditions: options.status?.conditions ?? [],
|
||||
};
|
||||
this.#generation = options.generation;
|
||||
}
|
||||
|
||||
public get generation() {
|
||||
return this.#generation;
|
||||
}
|
||||
|
||||
public get observedGeneration() {
|
||||
return this.#status.observedGeneration;
|
||||
}
|
||||
|
||||
public set observedGeneration(observedGeneration: number) {
|
||||
this.#status.observedGeneration = observedGeneration;
|
||||
}
|
||||
|
||||
public getCondition = (type: string) => {
|
||||
return this.#status.conditions?.find((condition) => condition.type === type)?.status;
|
||||
}
|
||||
|
||||
public setCondition = (type: string, condition: Omit<CustomResourceStatusType['conditions'][number], 'type' | 'lastTransitionTime'>) => {
|
||||
const currentCondition = this.getCondition(type);
|
||||
const newCondition = {
|
||||
...condition,
|
||||
type,
|
||||
lastTransitionTime: new Date().toISOString(),
|
||||
};
|
||||
if (currentCondition) {
|
||||
this.#status.conditions = this.#status.conditions.map((c) => c.type === type ? newCondition : c);
|
||||
} else {
|
||||
this.#status.conditions.push(newCondition);
|
||||
}
|
||||
}
|
||||
|
||||
public save = async () => {
|
||||
await this.#save({
|
||||
...this.#status,
|
||||
observedGeneration: this.#generation,
|
||||
});
|
||||
}
|
||||
|
||||
public toJSON = () => {
|
||||
return this.#status;
|
||||
}
|
||||
}
|
||||
|
||||
export { CustomResourceStatus, statusSchema, type CustomResourceStatusType };
|
||||
Reference in New Issue
Block a user