| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- import type { KubernetesObject, V1Deployment, V1Pod, V1Service } from '@kubernetes/client-node';
- import { wrapInfraError } from '$lib/server/errors';
- import type { K8sClient, WatchedResource } from '$lib/server/k8s';
- import { k8sLogger as log } from '$lib/server/logger';
- import type { ResourceEvent } from '$lib/types/events';
- import type { CoreResources } from '$lib/types/workspace';
- abstract class K8sResource<T extends KubernetesObject & { status?: unknown }> {
- #k8sClient: K8sClient;
- #namespace: string;
- #resource: WatchedResource;
- #childResources: WatchedResource[];
- #initialized = false;
- #initializationPromise?: Promise<void>;
- #handlers = new Set<(event: ResourceEvent) => void>();
- /** Stores JSON.stringify(cr.status) for change detection. */
- #statusCache = new Map<string, string>();
- constructor(
- k8sClient: K8sClient,
- namespace: string,
- resource: WatchedResource,
- childResources: WatchedResource[],
- ) {
- this.#k8sClient = k8sClient;
- this.#namespace = namespace;
- this.#resource = resource;
- this.#childResources = childResources;
- }
- subscribe(handler: (event: ResourceEvent) => void): () => void {
- this.#handlers.add(handler);
- return () => this.#handlers.delete(handler);
- }
- #emit(event: ResourceEvent): void {
- for (const h of this.#handlers) h(event);
- }
- #onResourceEvent(type: 'add' | 'update' | 'delete', cr: T): void {
- const id = cr.metadata?.name;
- if (!id) return;
- if (type === 'delete') return;
- const rawStatus = cr.status;
- const serialized = JSON.stringify(rawStatus ?? null);
- const prev = this.#statusCache.get(id);
- const isKnown = this.#statusCache.has(id);
- this.#statusCache.set(id, serialized);
- if (isKnown && serialized !== prev) {
- this.#emit({
- group: this.#resource.group,
- version: this.#resource.version,
- resource: this.#resource.resource,
- namespace: this.#namespace,
- name: id,
- status: rawStatus,
- });
- }
- }
- async init(): Promise<void> {
- if (this.#initialized) return;
- if (this.#initializationPromise === undefined) {
- const watchResources: Promise<void>[] = [];
- watchResources.push(
- this.#k8sClient
- .watchResources<T>(this.#resource, (type, cr) => this.#onResourceEvent(type, cr))
- .then(() => {}),
- );
- for (const res of this.#childResources) {
- watchResources.push(this.#k8sClient.watchResources(res).then(() => {}));
- }
- this.#initializationPromise = Promise.all(watchResources).then(() => {});
- }
- await this.#initializationPromise;
- this.#initialized = true;
- }
- async getResources(): Promise<T[]> {
- await this.init();
- return this.#k8sClient.listResources<T>(
- this.#resource.group,
- this.#resource.version,
- this.#resource.resource,
- this.#namespace,
- );
- }
- async getCoreResources(id: string): Promise<CoreResources> {
- await this.init();
- const deployments = this.#k8sClient
- .listResources<V1Deployment>('apps', 'v1', 'deployments', this.#namespace)
- .filter((d) => d.metadata?.labels?.['locostack.com/name'] === id);
- const pods = this.#k8sClient
- .listResources<V1Pod>('core', 'v1', 'pods', this.#namespace)
- .filter((p) => p.metadata?.labels?.['locostack.com/name'] === id);
- const services = this.#k8sClient
- .listResources<V1Service>('core', 'v1', 'services', this.#namespace)
- .filter((s) => s.metadata?.labels?.['locostack.com/name'] === id);
- return { type: 'k8s', deployments, pods, services };
- }
- async getResourceStatus<TStatus>(id: string): Promise<TStatus | undefined> {
- await this.init();
- return this.#k8sClient.getResourceStatus<TStatus>(
- this.#resource.group,
- this.#resource.version,
- this.#resource.resource,
- id,
- this.#namespace,
- );
- }
- /** Server-side apply the given CR. Errors are logged and swallowed (best-effort). */
- async apply(cr: T): Promise<void> {
- try {
- await this.#k8sClient.applyCustomResource(
- this.#resource.group,
- this.#resource.version,
- this.#resource.resource,
- this.#namespace,
- cr,
- );
- } catch (err) {
- log.warn('K8s apply failed (non-blocking)', {
- resource: this.#resource.resource,
- namespace: this.#namespace,
- err,
- });
- throw wrapInfraError('k8s', 'apply', err);
- }
- }
- /** Delete a CR by name. Errors are logged and swallowed (best-effort). */
- async remove(id: string): Promise<void> {
- try {
- await this.#k8sClient.deleteCustomResource(
- this.#resource.group,
- this.#resource.version,
- this.#resource.resource,
- this.#namespace,
- id,
- );
- } catch (err) {
- log.warn('K8s remove failed (non-blocking)', {
- resource: this.#resource.resource,
- namespace: this.#namespace,
- id,
- err,
- });
- throw wrapInfraError('k8s', 'delete', err);
- }
- }
- }
- export { K8sResource };
|