@vibesdotdev/infra-doks 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +107 -0
  2. package/SPEC.md +285 -0
  3. package/dist/client/digitalocean-app-deploy.client.d.ts +46 -0
  4. package/dist/client/digitalocean-app-deploy.client.d.ts.map +1 -0
  5. package/dist/client/digitalocean-app-deploy.client.js +135 -0
  6. package/dist/client/digitalocean-app-deploy.client.js.map +1 -0
  7. package/dist/client/index.d.ts +15 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +18 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/cloud/base.d.ts +33 -0
  12. package/dist/cloud/base.d.ts.map +1 -0
  13. package/dist/cloud/base.js +86 -0
  14. package/dist/cloud/base.js.map +1 -0
  15. package/dist/cloud/digitalocean.d.ts +33 -0
  16. package/dist/cloud/digitalocean.d.ts.map +1 -0
  17. package/dist/cloud/digitalocean.js +258 -0
  18. package/dist/cloud/digitalocean.js.map +1 -0
  19. package/dist/cloud/factory.d.ts +28 -0
  20. package/dist/cloud/factory.d.ts.map +1 -0
  21. package/dist/cloud/factory.js +151 -0
  22. package/dist/cloud/factory.js.map +1 -0
  23. package/dist/cloud/index.d.ts +12 -0
  24. package/dist/cloud/index.d.ts.map +1 -0
  25. package/dist/cloud/index.js +11 -0
  26. package/dist/cloud/index.js.map +1 -0
  27. package/dist/doks.plugin.d.ts +41 -0
  28. package/dist/doks.plugin.d.ts.map +1 -0
  29. package/dist/doks.plugin.js +287 -0
  30. package/dist/doks.plugin.js.map +1 -0
  31. package/dist/implementations/deployment.impl.d.ts +34 -0
  32. package/dist/implementations/deployment.impl.d.ts.map +1 -0
  33. package/dist/implementations/deployment.impl.js +86 -0
  34. package/dist/implementations/deployment.impl.js.map +1 -0
  35. package/dist/implementations/droplet.impl.d.ts +85 -0
  36. package/dist/implementations/droplet.impl.d.ts.map +1 -0
  37. package/dist/implementations/droplet.impl.js +113 -0
  38. package/dist/implementations/droplet.impl.js.map +1 -0
  39. package/dist/implementations/gitea.impl.d.ts +68 -0
  40. package/dist/implementations/gitea.impl.d.ts.map +1 -0
  41. package/dist/implementations/gitea.impl.js +295 -0
  42. package/dist/implementations/gitea.impl.js.map +1 -0
  43. package/dist/implementations/managed-db.impl.d.ts +25 -0
  44. package/dist/implementations/managed-db.impl.d.ts.map +1 -0
  45. package/dist/implementations/managed-db.impl.js +31 -0
  46. package/dist/implementations/managed-db.impl.js.map +1 -0
  47. package/dist/implementations/managed-redis.impl.d.ts +37 -0
  48. package/dist/implementations/managed-redis.impl.d.ts.map +1 -0
  49. package/dist/implementations/managed-redis.impl.js +40 -0
  50. package/dist/implementations/managed-redis.impl.js.map +1 -0
  51. package/dist/implementations/spaces.impl.d.ts +36 -0
  52. package/dist/implementations/spaces.impl.d.ts.map +1 -0
  53. package/dist/implementations/spaces.impl.js +40 -0
  54. package/dist/implementations/spaces.impl.js.map +1 -0
  55. package/dist/implementations/statefulset.impl.d.ts +65 -0
  56. package/dist/implementations/statefulset.impl.d.ts.map +1 -0
  57. package/dist/implementations/statefulset.impl.js +165 -0
  58. package/dist/implementations/statefulset.impl.js.map +1 -0
  59. package/dist/implementations/verdaccio.impl.d.ts +65 -0
  60. package/dist/implementations/verdaccio.impl.d.ts.map +1 -0
  61. package/dist/implementations/verdaccio.impl.js +259 -0
  62. package/dist/implementations/verdaccio.impl.js.map +1 -0
  63. package/dist/index.d.ts +15 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +19 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/kubernetes/index.d.ts +95 -0
  68. package/dist/kubernetes/index.d.ts.map +1 -0
  69. package/dist/kubernetes/index.js +625 -0
  70. package/dist/kubernetes/index.js.map +1 -0
  71. package/dist/secrets/index.d.ts +4 -0
  72. package/dist/secrets/index.d.ts.map +1 -0
  73. package/dist/secrets/index.js +4 -0
  74. package/dist/secrets/index.js.map +1 -0
  75. package/dist/secrets/vault.descriptor.d.ts +10 -0
  76. package/dist/secrets/vault.descriptor.d.ts.map +1 -0
  77. package/dist/secrets/vault.descriptor.js +25 -0
  78. package/dist/secrets/vault.descriptor.js.map +1 -0
  79. package/dist/secrets/vault.impl.cloud.d.ts +40 -0
  80. package/dist/secrets/vault.impl.cloud.d.ts.map +1 -0
  81. package/dist/secrets/vault.impl.cloud.js +178 -0
  82. package/dist/secrets/vault.impl.cloud.js.map +1 -0
  83. package/dist/secrets/vault.impl.d.ts +29 -0
  84. package/dist/secrets/vault.impl.d.ts.map +1 -0
  85. package/dist/secrets/vault.impl.js +137 -0
  86. package/dist/secrets/vault.impl.js.map +1 -0
  87. package/dist/types.d.ts +509 -0
  88. package/dist/types.d.ts.map +1 -0
  89. package/dist/types.js +47 -0
  90. package/dist/types.js.map +1 -0
  91. package/package.json +145 -0
  92. package/src/client/digitalocean-app-deploy.client.ts +226 -0
  93. package/src/client/index.ts +24 -0
  94. package/src/cloud/base.ts +149 -0
  95. package/src/cloud/digitalocean.ts +363 -0
  96. package/src/cloud/factory.ts +190 -0
  97. package/src/cloud/index.ts +81 -0
  98. package/src/doks.plugin.ts +401 -0
  99. package/src/implementations/deployment.impl.ts +93 -0
  100. package/src/implementations/droplet.impl.ts +157 -0
  101. package/src/implementations/gitea.impl.ts +319 -0
  102. package/src/implementations/managed-db.impl.ts +37 -0
  103. package/src/implementations/managed-redis.impl.ts +49 -0
  104. package/src/implementations/spaces.impl.ts +52 -0
  105. package/src/implementations/statefulset.impl.ts +186 -0
  106. package/src/implementations/verdaccio.impl.ts +300 -0
  107. package/src/index.ts +136 -0
  108. package/src/kubernetes/index.ts +754 -0
  109. package/src/secrets/index.ts +9 -0
  110. package/src/secrets/vault.descriptor.ts +28 -0
  111. package/src/secrets/vault.impl.cloud.ts +278 -0
  112. package/src/secrets/vault.impl.ts +149 -0
  113. package/src/types.ts +563 -0
@@ -0,0 +1,754 @@
1
+ /**
2
+ * Kubernetes Client Wrapper
3
+ *
4
+ * Thin wrapper around @kubernetes/client-node providing
5
+ * simplified access to common operations with proper typing
6
+ */
7
+
8
+ import * as k8s from '@kubernetes/client-node';
9
+ import { Readable, Writable } from 'stream';
10
+ import { getLogger } from '@vibesdotdev/logging';
11
+ import type {
12
+ K8sConfig,
13
+ K8sEnvironment,
14
+ PodSelector,
15
+ PodStatus,
16
+ LogOptions,
17
+ LogEntry,
18
+ LogStream,
19
+ DeploymentStatus,
20
+ ServiceInfo,
21
+ PodMetrics,
22
+ K8sEvent,
23
+ ExecOptions,
24
+ ExecResult,
25
+ PortForwardOptions,
26
+ PortForward,
27
+ ResourceMetrics,
28
+ ContainerState,
29
+ PodPhase,
30
+ ConditionStatus,
31
+ DeploymentConditionType,
32
+ ServiceType,
33
+ ServiceProtocol,
34
+ EventType
35
+ } from '../types.ts';
36
+ import { K8sAuthError, K8sError, K8sNotFoundError } from '../types.ts';
37
+
38
+ const logger = getLogger('infra-doks:k8s');
39
+
40
+ // K8s-specific types for internal use
41
+ interface K8sMetricsConstructor {
42
+ new (config: k8s.KubeConfig): K8sMetricsClient;
43
+ }
44
+
45
+ interface K8sMetricsClient {
46
+ getPodMetrics(namespace: string): Promise<K8sMetricsList>;
47
+ }
48
+
49
+ interface K8sMetricsList {
50
+ items: K8sPodMetric[];
51
+ }
52
+
53
+ interface K8sPodMetric {
54
+ metadata: {
55
+ name: string;
56
+ namespace: string;
57
+ };
58
+ timestamp: string;
59
+ containers: Array<{
60
+ name: string;
61
+ usage?: {
62
+ cpu?: string;
63
+ memory?: string;
64
+ };
65
+ }>;
66
+ }
67
+
68
+ export class KubernetesClient {
69
+ private kc: k8s.KubeConfig;
70
+ private coreApi: k8s.CoreV1Api;
71
+ private appsApi: k8s.AppsV1Api;
72
+ private metrics?: K8sMetricsClient;
73
+ private config: K8sConfig;
74
+
75
+ constructor(config: K8sConfig = {}) {
76
+ this.config = config;
77
+ this.kc = new k8s.KubeConfig();
78
+
79
+ // Load appropriate config
80
+ if (config.inCluster) {
81
+ this.kc.loadFromCluster();
82
+ } else if (config.kubeconfig) {
83
+ this.kc.loadFromFile(config.kubeconfig);
84
+ } else {
85
+ this.kc.loadFromDefault();
86
+ }
87
+
88
+ // Set context if specified
89
+ if (config.context) {
90
+ this.kc.setCurrentContext(config.context);
91
+ }
92
+
93
+ // Initialize API clients
94
+ this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api);
95
+ this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api);
96
+ // Prefer the stable Metrics helper exported by @kubernetes/client-node
97
+ try {
98
+ const MetricsCtor = k8s.Metrics;
99
+ if (MetricsCtor) this.metrics = new MetricsCtor(this.kc);
100
+ } catch {
101
+ this.metrics = undefined;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Get current environment info
107
+ */
108
+ getCurrentEnvironment(): K8sEnvironment {
109
+ const context = this.kc.getCurrentContext();
110
+ const cluster = this.kc.getCurrentCluster();
111
+
112
+ return {
113
+ name: context,
114
+ context: context,
115
+ namespace: this.config.namespace || 'default',
116
+ apiServer: cluster?.server,
117
+ provider: this.detectProvider(cluster?.server || '')
118
+ };
119
+ }
120
+
121
+ /**
122
+ * List available contexts
123
+ */
124
+ getContexts(): string[] {
125
+ return this.kc.getContexts().map((ctx) => ctx.name);
126
+ }
127
+
128
+ /**
129
+ * Switch to a different context
130
+ */
131
+ setContext(contextName: string): void {
132
+ this.kc.setCurrentContext(contextName);
133
+ // Reinitialize API clients
134
+ this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api);
135
+ this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api);
136
+ try {
137
+ const MetricsCtor = k8s.Metrics;
138
+ if (MetricsCtor) this.metrics = new MetricsCtor(this.kc);
139
+ else this.metrics = undefined;
140
+ } catch {
141
+ this.metrics = undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get pod status
147
+ */
148
+ async getPodStatus(selector: PodSelector): Promise<PodStatus[]> {
149
+ try {
150
+ const namespace = selector.namespace || this.config.namespace || 'default';
151
+
152
+ let pods: k8s.V1Pod[];
153
+ if (selector.name) {
154
+ const pod = await this.coreApi.readNamespacedPod({ name: selector.name, namespace });
155
+ pods = [pod];
156
+ } else {
157
+ const labelSelector = selector.labels
158
+ ? Object.entries(selector.labels)
159
+ .map(([k, v]) => `${k}=${v}`)
160
+ .join(',')
161
+ : undefined;
162
+
163
+ const response = await this.coreApi.listNamespacedPod({ namespace, labelSelector });
164
+ pods = response.items;
165
+ }
166
+
167
+ return pods.map((pod) => this.mapPodStatus(pod));
168
+ } catch (error: unknown) {
169
+ throw this.handleK8sError(error);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Stream logs from pods.
175
+ *
176
+ * Uses `k8s.Log.log()` (the pod-log streaming API) — NOT `k8s.Watch.watch()`,
177
+ * which is for resource-change events (ADDED/MODIFIED/DELETED) and is
178
+ * incompatible with the log endpoint. Each pod gets a `Writable` sink that
179
+ * splits incoming bytes into lines, parses them as `LogEntry`, and fans them
180
+ * out to subscribers. `close()` calls `controller.abort()` on every
181
+ * AbortController returned by `Log.log()` to cancel the upstream request.
182
+ */
183
+ async streamLogs(selector: PodSelector, options: LogOptions = {}): Promise<LogStream> {
184
+ const namespace = selector.namespace || this.config.namespace || 'default';
185
+ const pods = await this.getPodStatus(selector);
186
+
187
+ if (pods.length === 0) {
188
+ throw new K8sNotFoundError('Pod', JSON.stringify(selector), namespace);
189
+ }
190
+
191
+ const controllers: Map<string, AbortController> = new Map();
192
+ const listeners: Set<(log: LogEntry) => void> = new Set();
193
+ let isPaused = false;
194
+
195
+ const logClient = new k8s.Log(this.kc);
196
+
197
+ // Create a log stream per pod
198
+ for (const pod of pods) {
199
+ // Line-splitting Writable. Holds a buffer across writes so partial
200
+ // chunks at the boundary don't corrupt log entries.
201
+ let buffer = '';
202
+ const sink = new Writable({
203
+ write: (chunk: Buffer | string, _encoding, callback) => {
204
+ if (isPaused) {
205
+ callback();
206
+ return;
207
+ }
208
+ buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
209
+ const lines = buffer.split('\n');
210
+ buffer = lines.pop() ?? '';
211
+ for (const line of lines) {
212
+ if (!line.trim()) continue;
213
+ const logEntry = this.parseLogEntry(line, pod, namespace);
214
+ listeners.forEach((listener) => listener(logEntry));
215
+ }
216
+ callback();
217
+ },
218
+ final: (callback) => {
219
+ if (buffer.trim()) {
220
+ const logEntry = this.parseLogEntry(buffer, pod, namespace);
221
+ listeners.forEach((listener) => listener(logEntry));
222
+ buffer = '';
223
+ }
224
+ callback();
225
+ }
226
+ });
227
+
228
+ try {
229
+ const controller = await logClient.log(
230
+ namespace,
231
+ pod.name,
232
+ options.container ?? '',
233
+ sink,
234
+ {
235
+ follow: options.follow !== false,
236
+ tailLines: options.tailLines ?? 100,
237
+ timestamps: true,
238
+ previous: options.previous,
239
+ sinceSeconds: options.sinceSeconds,
240
+ limitBytes: options.limitBytes
241
+ }
242
+ );
243
+ controllers.set(pod.name, controller);
244
+ } catch (err) {
245
+ logger.error(`Log stream error for ${pod.name}:`, err);
246
+ }
247
+ }
248
+
249
+ return {
250
+ subscribe(callback: (log: LogEntry) => void): () => void {
251
+ listeners.add(callback);
252
+ return () => listeners.delete(callback);
253
+ },
254
+ pause() {
255
+ isPaused = true;
256
+ },
257
+ resume() {
258
+ isPaused = false;
259
+ },
260
+ close() {
261
+ controllers.forEach((controller) => controller.abort());
262
+ controllers.clear();
263
+ listeners.clear();
264
+ }
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Get logs as a single batch
270
+ */
271
+ async getLogs(selector: PodSelector, options: LogOptions = {}): Promise<LogEntry[]> {
272
+ const namespace = selector.namespace || this.config.namespace || 'default';
273
+ const pods = await this.getPodStatus(selector);
274
+
275
+ if (pods.length === 0) {
276
+ throw new K8sNotFoundError('Pod', JSON.stringify(selector), namespace);
277
+ }
278
+
279
+ const allLogs: LogEntry[] = [];
280
+
281
+ for (const pod of pods) {
282
+ try {
283
+ const logBody = await this.coreApi.readNamespacedPodLog({
284
+ name: pod.name,
285
+ namespace,
286
+ container: options.container,
287
+ follow: options.follow,
288
+ limitBytes: options.limitBytes,
289
+ previous: options.previous,
290
+ sinceSeconds: options.sinceSeconds,
291
+ tailLines: options.tailLines,
292
+ timestamps: options.timestamps
293
+ });
294
+
295
+ const lines = logBody.split('\n').filter((line: string) => line.trim());
296
+ const logs = lines.map((line: string) => this.parseLogEntry(line, pod, namespace));
297
+ allLogs.push(...logs);
298
+ } catch (error: unknown) {
299
+ logger.error(`Failed to get logs for ${pod.name}:`, error);
300
+ }
301
+ }
302
+
303
+ return allLogs.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
304
+ }
305
+
306
+ /**
307
+ * Get deployment status
308
+ */
309
+ async getDeploymentStatus(name: string, namespace?: string): Promise<DeploymentStatus> {
310
+ try {
311
+ const ns = namespace || this.config.namespace || 'default';
312
+ const deployment = await this.appsApi.readNamespacedDeployment({ name, namespace: ns });
313
+
314
+ return {
315
+ name: deployment.metadata!.name!,
316
+ namespace: deployment.metadata!.namespace!,
317
+ replicas: {
318
+ desired: deployment.spec!.replicas || 0,
319
+ current: deployment.status?.replicas || 0,
320
+ available: deployment.status?.availableReplicas || 0,
321
+ updated: deployment.status?.updatedReplicas || 0
322
+ },
323
+ conditions: (deployment.status?.conditions || []).map((c: k8s.V1DeploymentCondition) => ({
324
+ type: c.type as DeploymentConditionType,
325
+ status: c.status as ConditionStatus,
326
+ reason: c.reason,
327
+ message: c.message,
328
+ lastUpdateTime: new Date(c.lastUpdateTime!),
329
+ lastTransitionTime: new Date(c.lastTransitionTime!)
330
+ })),
331
+ strategy: deployment.spec!.strategy?.type || 'RollingUpdate',
332
+ minReadySeconds: deployment.spec!.minReadySeconds || 0,
333
+ revisionHistoryLimit: deployment.spec!.revisionHistoryLimit || 10,
334
+ selector: deployment.spec!.selector.matchLabels || {}
335
+ };
336
+ } catch (error: unknown) {
337
+ throw this.handleK8sError(error);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Get service info
343
+ */
344
+ async getServiceInfo(name: string, namespace?: string): Promise<ServiceInfo> {
345
+ try {
346
+ const ns = namespace || this.config.namespace || 'default';
347
+ const service = await this.coreApi.readNamespacedService({ name, namespace: ns });
348
+
349
+ return {
350
+ name: service.metadata!.name!,
351
+ namespace: service.metadata!.namespace!,
352
+ type: service.spec!.type as ServiceType,
353
+ clusterIP: service.spec!.clusterIP,
354
+ externalIPs: service.spec!.externalIPs,
355
+ loadBalancerIP: service.status?.loadBalancer?.ingress?.[0]?.ip,
356
+ ports: (service.spec!.ports || []).map((p: k8s.V1ServicePort) => ({
357
+ name: p.name,
358
+ protocol: p.protocol as ServiceProtocol,
359
+ port: p.port,
360
+ targetPort: (typeof p.targetPort === 'object'
361
+ ? (p.targetPort as { intVal?: number; strVal?: string }).intVal ||
362
+ (p.targetPort as { intVal?: number; strVal?: string }).strVal ||
363
+ 0
364
+ : p.targetPort) as number | string,
365
+ nodePort: p.nodePort
366
+ })),
367
+ selector: service.spec!.selector ?? null
368
+ };
369
+ } catch (error: unknown) {
370
+ throw this.handleK8sError(error);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Get pod metrics
376
+ */
377
+ async getPodMetrics(selector: PodSelector): Promise<PodMetrics[]> {
378
+ try {
379
+ const namespace = selector.namespace || this.config.namespace || 'default';
380
+
381
+ if (!this.metrics) {
382
+ throw new Error('Kubernetes Metrics API is not available in this client version');
383
+ }
384
+
385
+ const metricsList = await this.metrics.getPodMetrics(namespace);
386
+ const allMetrics = metricsList.items;
387
+
388
+ const pods = await this.getPodStatus(selector);
389
+ const podNames = new Set(pods.map((p) => p.name));
390
+
391
+ return allMetrics
392
+ .filter((m) => podNames.has(m.metadata.name))
393
+ .map((m) => ({
394
+ pod: m.metadata.name,
395
+ namespace: m.metadata.namespace,
396
+ containers: m.containers.reduce(
397
+ (acc, c) => {
398
+ acc[c.name] = {
399
+ cpu: { usage: this.parseCPU(c.usage?.cpu || '0') },
400
+ memory: { usage: this.parseMemory(c.usage?.memory || '0') },
401
+ timestamp: new Date(m.timestamp)
402
+ };
403
+ return acc;
404
+ },
405
+ {} as Record<string, ResourceMetrics>
406
+ )
407
+ }));
408
+ } catch (error: unknown) {
409
+ throw this.handleK8sError(error);
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Get events for a resource
415
+ */
416
+ async getEvents(kind: string, name: string, namespace?: string): Promise<K8sEvent[]> {
417
+ try {
418
+ const ns = namespace || this.config.namespace || 'default';
419
+ const fieldSelector = `involvedObject.name=${name},involvedObject.kind=${kind}`;
420
+
421
+ const response = await this.coreApi.listNamespacedEvent({
422
+ namespace: ns,
423
+ fieldSelector
424
+ });
425
+
426
+ return response.items.map((e: k8s.CoreV1Event) => ({
427
+ type: e.type as EventType,
428
+ reason: e.reason!,
429
+ message: e.message!,
430
+ source: {
431
+ component: e.source?.component,
432
+ host: e.source?.host
433
+ },
434
+ firstTimestamp: new Date(e.firstTimestamp!),
435
+ lastTimestamp: new Date(e.lastTimestamp!),
436
+ count: e.count || 1,
437
+ involvedObject: {
438
+ kind: e.involvedObject.kind!,
439
+ name: e.involvedObject.name!,
440
+ namespace: e.involvedObject.namespace
441
+ }
442
+ }));
443
+ } catch (error: unknown) {
444
+ throw this.handleK8sError(error);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Execute command in pod
450
+ */
451
+ exec(options: ExecOptions): Promise<ExecResult> {
452
+ const namespace = options.namespace || this.config.namespace || 'default';
453
+ const exec = new k8s.Exec(this.kc);
454
+
455
+ let stdout = '';
456
+ let stderr = '';
457
+ const stdoutStream = new Writable({
458
+ write(chunk, _encoding, callback) {
459
+ stdout += chunk.toString();
460
+ callback();
461
+ }
462
+ });
463
+ const stderrStream = new Writable({
464
+ write(chunk, _encoding, callback) {
465
+ stderr += chunk.toString();
466
+ callback();
467
+ }
468
+ });
469
+
470
+ return new Promise((resolve, _reject) => {
471
+ exec.exec(
472
+ namespace,
473
+ options.pod,
474
+ options.container ?? '',
475
+ options.command,
476
+ stdoutStream,
477
+ stderrStream,
478
+ null,
479
+ options.tty || false,
480
+ (status) => {
481
+ if (status.status === 'Success') {
482
+ resolve({ stdout, stderr, exitCode: 0 });
483
+ } else {
484
+ resolve({
485
+ stdout,
486
+ stderr,
487
+ exitCode: Number.parseInt(
488
+ String((status.details as { causes?: Array<{ reason?: string; message?: string }> } | undefined)?.causes?.[0]?.message ?? '1'),
489
+ 10
490
+ )
491
+ });
492
+ }
493
+ }
494
+ );
495
+ });
496
+ }
497
+
498
+ /**
499
+ * Port forward to a pod.
500
+ *
501
+ * `PortForward.portForward()` requires Node `Writable` for output (and
502
+ * optionally for err) and a Node `Readable` for input. The previous
503
+ * implementation passed `Writable.toWeb(new Writable())` cast through
504
+ * `unknown` — that produced a WHATWG `WritableStream` (not a Node
505
+ * `Writable`) and then lied about its type. Replace with a real
506
+ * pass-through Node `Writable` sink that forwards bytes to the logger.
507
+ */
508
+ async portForward(options: PortForwardOptions): Promise<PortForward> {
509
+ const namespace = options.namespace || this.config.namespace || 'default';
510
+ const forward = new k8s.PortForward(this.kc);
511
+
512
+ const output = new Writable({
513
+ write(chunk: Buffer | string, _encoding, callback) {
514
+ logger.debug('portForward stdout', {
515
+ pod: options.pod,
516
+ namespace,
517
+ bytes: typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
518
+ });
519
+ callback();
520
+ }
521
+ });
522
+ const errOutput = new Writable({
523
+ write(chunk: Buffer | string, _encoding, callback) {
524
+ logger.warn('portForward stderr', {
525
+ pod: options.pod,
526
+ namespace,
527
+ message: typeof chunk === 'string' ? chunk : chunk.toString('utf8')
528
+ });
529
+ callback();
530
+ }
531
+ });
532
+ // A do-nothing input stream — no bytes are pushed from this side; the
533
+ // `Readable` is required by the API signature.
534
+ const input = new Readable({ read() {} });
535
+
536
+ const server = await forward.portForward(
537
+ namespace,
538
+ options.pod,
539
+ [options.remotePort],
540
+ output,
541
+ errOutput,
542
+ input,
543
+ options.localPort
544
+ );
545
+ const socket = typeof server === 'function' ? server() : server;
546
+
547
+ return {
548
+ localPort: options.localPort,
549
+ remotePort: options.remotePort,
550
+ close: () => {
551
+ socket?.close();
552
+ input.push(null);
553
+ output.end();
554
+ errOutput.end();
555
+ }
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Check cluster health
561
+ */
562
+ async checkHealth(): Promise<{
563
+ healthy: boolean;
564
+ version?: string;
565
+ message?: string;
566
+ }> {
567
+ try {
568
+ const versionApi = this.kc.makeApiClient(k8s.VersionApi);
569
+ const versionInfo = await versionApi.getCode();
570
+ const version = versionInfo.gitVersion;
571
+ return { healthy: true, version };
572
+ } catch (error: unknown) {
573
+ return {
574
+ healthy: false,
575
+ message: error instanceof Error ? error.message : 'Unknown error'
576
+ };
577
+ }
578
+ }
579
+
580
+ // Private helper methods
581
+
582
+ private mapPodStatus(pod: k8s.V1Pod): PodStatus {
583
+ const containerStatuses = pod.status?.containerStatuses || [];
584
+ const conditions = pod.status?.conditions || [];
585
+
586
+ return {
587
+ name: pod.metadata!.name!,
588
+ namespace: pod.metadata!.namespace!,
589
+ phase: (pod.status?.phase as PodPhase) || 'Unknown',
590
+ ready: conditions.some((c) => c.type === 'Ready' && c.status === 'True'),
591
+ restarts: containerStatuses.reduce((sum, cs) => sum + cs.restartCount, 0),
592
+ age: this.getAge(pod.metadata!.creationTimestamp),
593
+ ip: pod.status?.podIP,
594
+ node: pod.spec?.nodeName,
595
+ conditions: conditions.map((c) => ({
596
+ type: c.type,
597
+ status: c.status as ConditionStatus,
598
+ reason: c.reason,
599
+ message: c.message,
600
+ lastTransitionTime: new Date(c.lastTransitionTime!)
601
+ })),
602
+ containers: containerStatuses.map((cs) => ({
603
+ name: cs.name,
604
+ ready: cs.ready,
605
+ restartCount: cs.restartCount,
606
+ state: this.mapContainerState(cs.state!),
607
+ image: cs.image,
608
+ imageID: cs.imageID
609
+ }))
610
+ };
611
+ }
612
+
613
+ private mapContainerState(state: k8s.V1ContainerState): ContainerState {
614
+ if (state.running) {
615
+ return {
616
+ running: {
617
+ startedAt: new Date(state.running.startedAt!)
618
+ }
619
+ };
620
+ } else if (state.terminated) {
621
+ return {
622
+ terminated: {
623
+ exitCode: state.terminated.exitCode,
624
+ reason: state.terminated.reason,
625
+ message: state.terminated.message,
626
+ startedAt: state.terminated.startedAt ? new Date(state.terminated.startedAt) : undefined,
627
+ finishedAt: state.terminated.finishedAt
628
+ ? new Date(state.terminated.finishedAt)
629
+ : undefined
630
+ }
631
+ };
632
+ } else if (state.waiting) {
633
+ return {
634
+ waiting: {
635
+ reason: state.waiting.reason,
636
+ message: state.waiting.message
637
+ }
638
+ };
639
+ }
640
+ return {};
641
+ }
642
+
643
+ private parseLogEntry(line: string, pod: PodStatus, namespace: string): LogEntry {
644
+ // Parse timestamp if present (when timestamps: true)
645
+ const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/);
646
+ let timestamp: Date;
647
+ let message: string;
648
+
649
+ if (timestampMatch) {
650
+ timestamp = new Date(timestampMatch[1] ?? '');
651
+ message = timestampMatch[2] ?? '';
652
+ } else {
653
+ timestamp = new Date();
654
+ message = line;
655
+ }
656
+
657
+ // Try to parse log level
658
+ const levelMatch = message.match(/\b(ERROR|WARN|INFO|DEBUG|TRACE)\b/i);
659
+ const level = levelMatch ? levelMatch[1].toUpperCase() : undefined;
660
+
661
+ return {
662
+ timestamp,
663
+ pod: pod.name,
664
+ namespace,
665
+ container: pod.containers[0]?.name || 'unknown',
666
+ message,
667
+ level,
668
+ cluster: this.kc.getCurrentContext(),
669
+ node: pod.node,
670
+ labels: {}
671
+ };
672
+ }
673
+
674
+ private getAge(timestamp?: Date | string): string {
675
+ if (!timestamp) return 'Unknown';
676
+
677
+ const created = new Date(timestamp);
678
+ const now = new Date();
679
+ const diff = now.getTime() - created.getTime();
680
+
681
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
682
+ const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
683
+ const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
684
+
685
+ if (days > 0) return `${days}d${hours}h`;
686
+ if (hours > 0) return `${hours}h${minutes}m`;
687
+ return `${minutes}m`;
688
+ }
689
+
690
+ private parseCPU(cpu: string): number {
691
+ // Convert CPU to millicores
692
+ if (cpu.endsWith('n')) {
693
+ return parseInt(cpu) / 1_000_000;
694
+ } else if (cpu.endsWith('m')) {
695
+ return parseInt(cpu);
696
+ } else {
697
+ return parseFloat(cpu) * 1000;
698
+ }
699
+ }
700
+
701
+ private parseMemory(memory: string): number {
702
+ // Convert memory to bytes
703
+ const units: Record<string, number> = {
704
+ Ki: 1024,
705
+ Mi: 1024 * 1024,
706
+ Gi: 1024 * 1024 * 1024,
707
+ K: 1000,
708
+ M: 1000 * 1000,
709
+ G: 1000 * 1000 * 1000
710
+ };
711
+
712
+ for (const [unit, multiplier] of Object.entries(units)) {
713
+ if (memory.endsWith(unit)) {
714
+ return parseInt(memory) * multiplier;
715
+ }
716
+ }
717
+
718
+ return parseInt(memory);
719
+ }
720
+
721
+ private detectProvider(server: string): K8sEnvironment['provider'] {
722
+ if (server.includes('eks.amazonaws.com')) return 'eks';
723
+ if (server.includes('azmk8s.io')) return 'aks';
724
+ if (server.includes('gke.')) return 'gke';
725
+ if (server.includes('docker-desktop')) return 'docker-desktop';
726
+ if (server.includes('minikube')) return 'minikube';
727
+ return 'other';
728
+ }
729
+
730
+ private handleK8sError(error: unknown): Error {
731
+ if ((error as { response?: { statusCode: number; body?: { message?: string } } }).response) {
732
+ const response = (error as { response: { statusCode: number; body?: { message?: string } } }).response;
733
+ const status = response.statusCode;
734
+ const message = response.body?.message || String(error);
735
+
736
+ if (status === 401 || status === 403) {
737
+ return new K8sAuthError(message);
738
+ }
739
+
740
+ if (status === 404) {
741
+ return new K8sNotFoundError(
742
+ (response.body as { details?: { kind?: string } } | undefined)?.details?.kind || 'Resource',
743
+ (response.body as { details?: { name?: string } } | undefined)?.details?.name || 'unknown'
744
+ );
745
+ }
746
+
747
+ return new K8sError(message, status, (error as { response?: { body?: { reason?: string } } }).response?.body?.reason);
748
+ }
749
+
750
+ return error instanceof Error ? error : new Error('Unknown Kubernetes error');
751
+ }
752
+ }
753
+
754
+ export default KubernetesClient;