From 9e5081ed9bb54fd7962ccb26d2cca269d5616d76 Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Wed, 20 Aug 2025 14:58:34 +0200 Subject: [PATCH] updates --- .../authentik-server/authentik-server.ts | 2 +- .../homelab/environment/environment.ts | 2 +- .../destination-rule/destination-rule.ts | 23 ++++++- src/resources/istio/gateway/gateway.ts | 28 +++++++- .../istio/virtual-service/virtual-service.ts | 23 ++++++- .../resources/resource/resource.custom.ts | 66 ++++++++++++++++++- src/services/resources/resource/resource.ts | 22 ------- src/utils/errors.ts | 14 ++++ 8 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 src/utils/errors.ts diff --git a/src/resources/homelab/authentik-server/authentik-server.ts b/src/resources/homelab/authentik-server/authentik-server.ts index d6b6edf..556f22c 100644 --- a/src/resources/homelab/authentik-server/authentik-server.ts +++ b/src/resources/homelab/authentik-server/authentik-server.ts @@ -215,7 +215,7 @@ class AuthentikServer extends CustomResource { }); const gateway = this.#environment.current.gateway; - await this.#virtualService.ensure({ + await this.#virtualService.set({ metadata: { ownerReferences: [this.ref], }, diff --git a/src/resources/homelab/environment/environment.ts b/src/resources/homelab/environment/environment.ts index 7398e1b..01376ad 100644 --- a/src/resources/homelab/environment/environment.ts +++ b/src/resources/homelab/environment/environment.ts @@ -146,7 +146,7 @@ class Environment extends CustomResource { }, }); - await this.#gateway.ensure({ + await this.#gateway.set({ metadata: { ownerReferences: [this.ref], }, diff --git a/src/resources/istio/destination-rule/destination-rule.ts b/src/resources/istio/destination-rule/destination-rule.ts index 57a42db..1d1d441 100644 --- a/src/resources/istio/destination-rule/destination-rule.ts +++ b/src/resources/istio/destination-rule/destination-rule.ts @@ -1,11 +1,32 @@ import type { KubernetesObject } from '@kubernetes/client-node'; import type { K8SDestinationRuleV1 } from 'src/__generated__/resources/K8SDestinationRuleV1.ts'; -import { Resource } from '#services/resources/resources.ts'; +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; +import { CRD } from '#resources/core/crd/crd.ts'; +import { NotReadyError } from '#utils/errors.ts'; class DestinationRule extends Resource { public static readonly apiVersion = 'networking.istio.io/v1'; public static readonly kind = 'DestinationRule'; + + #crd: CRD; + + constructor(options: ResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#crd = resourceService.get(CRD, 'destinationrules.networking.istio.io'); + } + + public get hasCRD() { + return this.#crd.exists; + } + + public set = async (manifest: KubernetesObject & K8SDestinationRuleV1) => { + if (!this.hasCRD) { + throw new NotReadyError('CRD is not installed'); + } + await this.ensure(manifest); + }; } export { DestinationRule }; diff --git a/src/resources/istio/gateway/gateway.ts b/src/resources/istio/gateway/gateway.ts index f2675c0..c1a9675 100644 --- a/src/resources/istio/gateway/gateway.ts +++ b/src/resources/istio/gateway/gateway.ts @@ -1,11 +1,37 @@ import type { KubernetesObject } from '@kubernetes/client-node'; import type { K8SGatewayV1 } from 'src/__generated__/resources/K8SGatewayV1.ts'; -import { Resource } from '#services/resources/resources.ts'; +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; +import { CRD } from '#resources/core/crd/crd.ts'; +import { NotReadyError } from '#utils/errors.ts'; class Gateway extends Resource { public static readonly apiVersion = 'networking.istio.io/v1'; public static readonly kind = 'Gateway'; + + #crd: CRD; + + constructor(options: ResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#crd = resourceService.get(CRD, 'gateways.networking.istio.io'); + this.on('changed', this.#handleUpdate); + } + + #handleUpdate = async () => { + this.emit('changed'); + }; + + public get hasCRD() { + return this.#crd.exists; + } + + public set = async (manifest: KubernetesObject & K8SGatewayV1) => { + if (!this.hasCRD) { + throw new NotReadyError('CRD is not installed'); + } + await this.ensure(manifest); + }; } export { Gateway }; diff --git a/src/resources/istio/virtual-service/virtual-service.ts b/src/resources/istio/virtual-service/virtual-service.ts index 804ca16..e103e1b 100644 --- a/src/resources/istio/virtual-service/virtual-service.ts +++ b/src/resources/istio/virtual-service/virtual-service.ts @@ -1,11 +1,32 @@ import type { KubernetesObject } from '@kubernetes/client-node'; import type { K8SVirtualServiceV1 } from 'src/__generated__/resources/K8SVirtualServiceV1.ts'; -import { Resource } from '#services/resources/resources.ts'; +import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts'; +import { CRD } from '#resources/core/crd/crd.ts'; +import { NotReadyError } from '#utils/errors.ts'; class VirtualService extends Resource { public static readonly apiVersion = 'networking.istio.io/v1'; public static readonly kind = 'VirtualService'; + + #crd: CRD; + + constructor(options: ResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#crd = resourceService.get(CRD, 'virtualservices.networking.istio.io'); + } + + public get hasCRD() { + return this.#crd.exists; + } + + public set = async (manifest: KubernetesObject & K8SVirtualServiceV1) => { + if (!this.hasCRD) { + throw new NotReadyError('CRD is not installed'); + } + await this.ensure(manifest); + }; } export { VirtualService }; diff --git a/src/services/resources/resource/resource.custom.ts b/src/services/resources/resource/resource.custom.ts index d26e339..2256cf9 100644 --- a/src/services/resources/resource/resource.custom.ts +++ b/src/services/resources/resource/resource.custom.ts @@ -1,9 +1,11 @@ import { z, type ZodType } from 'zod'; -import type { KubernetesObject } from '@kubernetes/client-node'; +import { type KubernetesObject } from '@kubernetes/client-node'; import { Resource, type ResourceOptions } from './resource.ts'; import { API_VERSION } from '#utils/consts.ts'; +import { CoalescingQueued } from '#utils/queues.ts'; +import { NotReadyError } from '#utils/errors.ts'; const customResourceStatusSchema = z.object({ observedGeneration: z.number().optional(), @@ -26,9 +28,69 @@ const customResourceStatusSchema = z.object({ type CustomResourceOptions = ResourceOptions }>; -class CustomResource extends Resource }> { +class CustomResource extends Resource< + KubernetesObject & { spec: z.infer; status?: z.infer } +> { public static readonly apiVersion = API_VERSION; public static readonly status = customResourceStatusSchema; + + #reconcileQueue: CoalescingQueued; + + constructor(options: CustomResourceOptions) { + super(options); + this.#reconcileQueue = new CoalescingQueued({ + action: async () => { + try { + if (!this.exists || !this.manifest?.metadata?.deletionTimestamp) { + return; + } + this.services.log.debug('Reconciling', { + apiVersion: this.apiVersion, + kind: this.kind, + namespace: this.namespace, + name: this.name, + }); + await this.reconcile?.(); + } catch (err) { + if (err instanceof NotReadyError) { + console.error(err); + } else { + throw err; + } + } + }, + }); + this.on('changed', this.#handleUpdate); + } + + public get isSeen() { + return this.metadata?.generation === this.status?.observedGeneration; + } + + #handleUpdate = async () => { + if (this.isSeen) { + return; + } + return await this.queueReconcile(); + }; + + public reconcile?: () => Promise; + public queueReconcile = () => { + return this.#reconcileQueue.run(); + }; + + public markSeen = async () => { + if (this.isSeen) { + return; + } + await this.patchStatus({ + observedGeneration: this.metadata?.generation, + }); + }; + + public patchStatus = async (status: Partial>) => { + this.patch({ status } as ExpectedAny); + }; } export { CustomResource, type CustomResourceOptions }; diff --git a/src/services/resources/resource/resource.ts b/src/services/resources/resource/resource.ts index 74bbc3b..496679c 100644 --- a/src/services/resources/resource/resource.ts +++ b/src/services/resources/resource/resource.ts @@ -7,8 +7,6 @@ import { Queue } from '../../queue/queue.ts'; import { K8sService } from '../../k8s/k8s.ts'; import { isDeepSubset } from '../../../utils/objects.ts'; -import { CoalescingQueued } from '#utils/queues.ts'; - type ResourceSelector = { apiVersion: string; kind: string; @@ -29,7 +27,6 @@ type ResourceEvents = { class Resource extends EventEmitter { #manifest?: T; #queue: Queue; - #reconcileQueue: CoalescingQueued; #options: ResourceOptions; constructor(options: ResourceOptions) { @@ -37,27 +34,8 @@ class Resource extends EventEmitter this.#options = options; this.#manifest = options.manifest; this.#queue = new Queue({ concurrency: 1 }); - this.#reconcileQueue = new CoalescingQueued({ - action: async () => { - try { - if (!this.exists || !this.reconcile) { - return; - } - console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name); - await this.reconcile?.(); - } catch (err) { - console.error(err); - } - }, - }); - this.on('changed', this.queueReconcile); } - public reconcile?: () => Promise; - public queueReconcile = () => { - return this.#reconcileQueue.run(); - }; - public get services() { return this.#options.services; } diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..114bfe2 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,14 @@ +class NotReadyError extends Error { + #reason?: string; + + constructor(reason?: string, message?: string) { + super(message || reason || 'Resource is not ready'); + this.#reason = reason; + } + + get reason() { + return this.#reason; + } +} + +export { NotReadyError };