k8s.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import type { KubernetesObject, V1Deployment, V1Pod, V1Service } from '@kubernetes/client-node';
  2. import { wrapInfraError } from '$lib/server/errors';
  3. import type { K8sClient, WatchedResource } from '$lib/server/k8s';
  4. import { k8sLogger as log } from '$lib/server/logger';
  5. import type { ResourceEvent } from '$lib/types/events';
  6. import type { CoreResources } from '$lib/types/workspace';
  7. abstract class K8sResource<T extends KubernetesObject & { status?: unknown }> {
  8. #k8sClient: K8sClient;
  9. #namespace: string;
  10. #resource: WatchedResource;
  11. #childResources: WatchedResource[];
  12. #initialized = false;
  13. #initializationPromise?: Promise<void>;
  14. #handlers = new Set<(event: ResourceEvent) => void>();
  15. /** Stores JSON.stringify(cr.status) for change detection. */
  16. #statusCache = new Map<string, string>();
  17. constructor(
  18. k8sClient: K8sClient,
  19. namespace: string,
  20. resource: WatchedResource,
  21. childResources: WatchedResource[],
  22. ) {
  23. this.#k8sClient = k8sClient;
  24. this.#namespace = namespace;
  25. this.#resource = resource;
  26. this.#childResources = childResources;
  27. }
  28. subscribe(handler: (event: ResourceEvent) => void): () => void {
  29. this.#handlers.add(handler);
  30. return () => this.#handlers.delete(handler);
  31. }
  32. #emit(event: ResourceEvent): void {
  33. for (const h of this.#handlers) h(event);
  34. }
  35. #onResourceEvent(type: 'add' | 'update' | 'delete', cr: T): void {
  36. const id = cr.metadata?.name;
  37. if (!id) return;
  38. if (type === 'delete') return;
  39. const rawStatus = cr.status;
  40. const serialized = JSON.stringify(rawStatus ?? null);
  41. const prev = this.#statusCache.get(id);
  42. const isKnown = this.#statusCache.has(id);
  43. this.#statusCache.set(id, serialized);
  44. if (isKnown && serialized !== prev) {
  45. this.#emit({
  46. group: this.#resource.group,
  47. version: this.#resource.version,
  48. resource: this.#resource.resource,
  49. namespace: this.#namespace,
  50. name: id,
  51. status: rawStatus,
  52. });
  53. }
  54. }
  55. async init(): Promise<void> {
  56. if (this.#initialized) return;
  57. if (this.#initializationPromise === undefined) {
  58. const watchResources: Promise<void>[] = [];
  59. watchResources.push(
  60. this.#k8sClient
  61. .watchResources<T>(this.#resource, (type, cr) => this.#onResourceEvent(type, cr))
  62. .then(() => {}),
  63. );
  64. for (const res of this.#childResources) {
  65. watchResources.push(this.#k8sClient.watchResources(res).then(() => {}));
  66. }
  67. this.#initializationPromise = Promise.all(watchResources).then(() => {});
  68. }
  69. await this.#initializationPromise;
  70. this.#initialized = true;
  71. }
  72. async getResources(): Promise<T[]> {
  73. await this.init();
  74. return this.#k8sClient.listResources<T>(
  75. this.#resource.group,
  76. this.#resource.version,
  77. this.#resource.resource,
  78. this.#namespace,
  79. );
  80. }
  81. async getCoreResources(id: string): Promise<CoreResources> {
  82. await this.init();
  83. const deployments = this.#k8sClient
  84. .listResources<V1Deployment>('apps', 'v1', 'deployments', this.#namespace)
  85. .filter((d) => d.metadata?.labels?.['locostack.com/name'] === id);
  86. const pods = this.#k8sClient
  87. .listResources<V1Pod>('core', 'v1', 'pods', this.#namespace)
  88. .filter((p) => p.metadata?.labels?.['locostack.com/name'] === id);
  89. const services = this.#k8sClient
  90. .listResources<V1Service>('core', 'v1', 'services', this.#namespace)
  91. .filter((s) => s.metadata?.labels?.['locostack.com/name'] === id);
  92. return { type: 'k8s', deployments, pods, services };
  93. }
  94. async getResourceStatus<TStatus>(id: string): Promise<TStatus | undefined> {
  95. await this.init();
  96. return this.#k8sClient.getResourceStatus<TStatus>(
  97. this.#resource.group,
  98. this.#resource.version,
  99. this.#resource.resource,
  100. id,
  101. this.#namespace,
  102. );
  103. }
  104. /** Server-side apply the given CR. Errors are logged and swallowed (best-effort). */
  105. async apply(cr: T): Promise<void> {
  106. try {
  107. await this.#k8sClient.applyCustomResource(
  108. this.#resource.group,
  109. this.#resource.version,
  110. this.#resource.resource,
  111. this.#namespace,
  112. cr,
  113. );
  114. } catch (err) {
  115. log.warn('K8s apply failed (non-blocking)', {
  116. resource: this.#resource.resource,
  117. namespace: this.#namespace,
  118. err,
  119. });
  120. throw wrapInfraError('k8s', 'apply', err);
  121. }
  122. }
  123. /** Delete a CR by name. Errors are logged and swallowed (best-effort). */
  124. async remove(id: string): Promise<void> {
  125. try {
  126. await this.#k8sClient.deleteCustomResource(
  127. this.#resource.group,
  128. this.#resource.version,
  129. this.#resource.resource,
  130. this.#namespace,
  131. id,
  132. );
  133. } catch (err) {
  134. log.warn('K8s remove failed (non-blocking)', {
  135. resource: this.#resource.resource,
  136. namespace: this.#namespace,
  137. id,
  138. err,
  139. });
  140. throw wrapInfraError('k8s', 'delete', err);
  141. }
  142. }
  143. }
  144. export { K8sResource };