@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.
- package/README.md +107 -0
- package/SPEC.md +285 -0
- package/dist/client/digitalocean-app-deploy.client.d.ts +46 -0
- package/dist/client/digitalocean-app-deploy.client.d.ts.map +1 -0
- package/dist/client/digitalocean-app-deploy.client.js +135 -0
- package/dist/client/digitalocean-app-deploy.client.js.map +1 -0
- package/dist/client/index.d.ts +15 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +18 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cloud/base.d.ts +33 -0
- package/dist/cloud/base.d.ts.map +1 -0
- package/dist/cloud/base.js +86 -0
- package/dist/cloud/base.js.map +1 -0
- package/dist/cloud/digitalocean.d.ts +33 -0
- package/dist/cloud/digitalocean.d.ts.map +1 -0
- package/dist/cloud/digitalocean.js +258 -0
- package/dist/cloud/digitalocean.js.map +1 -0
- package/dist/cloud/factory.d.ts +28 -0
- package/dist/cloud/factory.d.ts.map +1 -0
- package/dist/cloud/factory.js +151 -0
- package/dist/cloud/factory.js.map +1 -0
- package/dist/cloud/index.d.ts +12 -0
- package/dist/cloud/index.d.ts.map +1 -0
- package/dist/cloud/index.js +11 -0
- package/dist/cloud/index.js.map +1 -0
- package/dist/doks.plugin.d.ts +41 -0
- package/dist/doks.plugin.d.ts.map +1 -0
- package/dist/doks.plugin.js +287 -0
- package/dist/doks.plugin.js.map +1 -0
- package/dist/implementations/deployment.impl.d.ts +34 -0
- package/dist/implementations/deployment.impl.d.ts.map +1 -0
- package/dist/implementations/deployment.impl.js +86 -0
- package/dist/implementations/deployment.impl.js.map +1 -0
- package/dist/implementations/droplet.impl.d.ts +85 -0
- package/dist/implementations/droplet.impl.d.ts.map +1 -0
- package/dist/implementations/droplet.impl.js +113 -0
- package/dist/implementations/droplet.impl.js.map +1 -0
- package/dist/implementations/gitea.impl.d.ts +68 -0
- package/dist/implementations/gitea.impl.d.ts.map +1 -0
- package/dist/implementations/gitea.impl.js +295 -0
- package/dist/implementations/gitea.impl.js.map +1 -0
- package/dist/implementations/managed-db.impl.d.ts +25 -0
- package/dist/implementations/managed-db.impl.d.ts.map +1 -0
- package/dist/implementations/managed-db.impl.js +31 -0
- package/dist/implementations/managed-db.impl.js.map +1 -0
- package/dist/implementations/managed-redis.impl.d.ts +37 -0
- package/dist/implementations/managed-redis.impl.d.ts.map +1 -0
- package/dist/implementations/managed-redis.impl.js +40 -0
- package/dist/implementations/managed-redis.impl.js.map +1 -0
- package/dist/implementations/spaces.impl.d.ts +36 -0
- package/dist/implementations/spaces.impl.d.ts.map +1 -0
- package/dist/implementations/spaces.impl.js +40 -0
- package/dist/implementations/spaces.impl.js.map +1 -0
- package/dist/implementations/statefulset.impl.d.ts +65 -0
- package/dist/implementations/statefulset.impl.d.ts.map +1 -0
- package/dist/implementations/statefulset.impl.js +165 -0
- package/dist/implementations/statefulset.impl.js.map +1 -0
- package/dist/implementations/verdaccio.impl.d.ts +65 -0
- package/dist/implementations/verdaccio.impl.d.ts.map +1 -0
- package/dist/implementations/verdaccio.impl.js +259 -0
- package/dist/implementations/verdaccio.impl.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/kubernetes/index.d.ts +95 -0
- package/dist/kubernetes/index.d.ts.map +1 -0
- package/dist/kubernetes/index.js +625 -0
- package/dist/kubernetes/index.js.map +1 -0
- package/dist/secrets/index.d.ts +4 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +4 -0
- package/dist/secrets/index.js.map +1 -0
- package/dist/secrets/vault.descriptor.d.ts +10 -0
- package/dist/secrets/vault.descriptor.d.ts.map +1 -0
- package/dist/secrets/vault.descriptor.js +25 -0
- package/dist/secrets/vault.descriptor.js.map +1 -0
- package/dist/secrets/vault.impl.cloud.d.ts +40 -0
- package/dist/secrets/vault.impl.cloud.d.ts.map +1 -0
- package/dist/secrets/vault.impl.cloud.js +178 -0
- package/dist/secrets/vault.impl.cloud.js.map +1 -0
- package/dist/secrets/vault.impl.d.ts +29 -0
- package/dist/secrets/vault.impl.d.ts.map +1 -0
- package/dist/secrets/vault.impl.js +137 -0
- package/dist/secrets/vault.impl.js.map +1 -0
- package/dist/types.d.ts +509 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +47 -0
- package/dist/types.js.map +1 -0
- package/package.json +145 -0
- package/src/client/digitalocean-app-deploy.client.ts +226 -0
- package/src/client/index.ts +24 -0
- package/src/cloud/base.ts +149 -0
- package/src/cloud/digitalocean.ts +363 -0
- package/src/cloud/factory.ts +190 -0
- package/src/cloud/index.ts +81 -0
- package/src/doks.plugin.ts +401 -0
- package/src/implementations/deployment.impl.ts +93 -0
- package/src/implementations/droplet.impl.ts +157 -0
- package/src/implementations/gitea.impl.ts +319 -0
- package/src/implementations/managed-db.impl.ts +37 -0
- package/src/implementations/managed-redis.impl.ts +49 -0
- package/src/implementations/spaces.impl.ts +52 -0
- package/src/implementations/statefulset.impl.ts +186 -0
- package/src/implementations/verdaccio.impl.ts +300 -0
- package/src/index.ts +136 -0
- package/src/kubernetes/index.ts +754 -0
- package/src/secrets/index.ts +9 -0
- package/src/secrets/vault.descriptor.ts +28 -0
- package/src/secrets/vault.impl.cloud.ts +278 -0
- package/src/secrets/vault.impl.ts +149 -0
- 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;
|