@forgeportal/plugin-kubernetes 1.3.0

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 (55) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +21 -0
  3. package/dist/KubernetesTab.d.ts +8 -0
  4. package/dist/KubernetesTab.d.ts.map +1 -0
  5. package/dist/KubernetesTab.js +91 -0
  6. package/dist/KubernetesTab.js.map +1 -0
  7. package/dist/LogsDrawer.d.ts +11 -0
  8. package/dist/LogsDrawer.d.ts.map +1 -0
  9. package/dist/LogsDrawer.js +21 -0
  10. package/dist/LogsDrawer.js.map +1 -0
  11. package/dist/PodStatusBadge.d.ts +7 -0
  12. package/dist/PodStatusBadge.d.ts.map +1 -0
  13. package/dist/PodStatusBadge.js +21 -0
  14. package/dist/PodStatusBadge.js.map +1 -0
  15. package/dist/__tests__/api-client.test.d.ts +2 -0
  16. package/dist/__tests__/api-client.test.d.ts.map +1 -0
  17. package/dist/__tests__/api-client.test.js +260 -0
  18. package/dist/__tests__/api-client.test.js.map +1 -0
  19. package/dist/actions.d.ts +27 -0
  20. package/dist/actions.d.ts.map +1 -0
  21. package/dist/actions.js +124 -0
  22. package/dist/actions.js.map +1 -0
  23. package/dist/api-client.d.ts +27 -0
  24. package/dist/api-client.d.ts.map +1 -0
  25. package/dist/api-client.js +193 -0
  26. package/dist/api-client.js.map +1 -0
  27. package/dist/index.d.ts +14 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +35 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/routes.d.ts +14 -0
  32. package/dist/routes.d.ts.map +1 -0
  33. package/dist/routes.js +115 -0
  34. package/dist/routes.js.map +1 -0
  35. package/dist/types.d.ts +138 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +3 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/ui.d.ts +11 -0
  40. package/dist/ui.d.ts.map +1 -0
  41. package/dist/ui.js +18 -0
  42. package/dist/ui.js.map +1 -0
  43. package/forgeportal-plugin.json +32 -0
  44. package/package.json +51 -0
  45. package/src/KubernetesTab.tsx +391 -0
  46. package/src/LogsDrawer.tsx +83 -0
  47. package/src/PodStatusBadge.tsx +36 -0
  48. package/src/__tests__/api-client.test.ts +324 -0
  49. package/src/actions.ts +146 -0
  50. package/src/api-client.ts +248 -0
  51. package/src/index.ts +46 -0
  52. package/src/routes.ts +154 -0
  53. package/src/types.ts +103 -0
  54. package/src/ui.ts +19 -0
  55. package/tsconfig.json +11 -0
@@ -0,0 +1,83 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { useApi } from '@forgeportal/plugin-sdk/react';
3
+
4
+ interface LogsDrawerProps {
5
+ entityId: string;
6
+ podName: string;
7
+ namespace: string;
8
+ cluster?: string;
9
+ onClose: () => void;
10
+ }
11
+
12
+ export function LogsDrawer({
13
+ entityId,
14
+ podName,
15
+ namespace,
16
+ cluster,
17
+ onClose,
18
+ }: LogsDrawerProps): React.ReactElement {
19
+ const params = new URLSearchParams({ namespace, ...(cluster ? { cluster } : {}) });
20
+ const url = `/api/v1/plugins/kubernetes/entities/${entityId}/pods/${encodeURIComponent(podName)}/logs?${params.toString()}`;
21
+
22
+ const { data: logsResponse, isPending, isError, error } = useApi<{ data: { logs: string } }>(url, {
23
+ refetchInterval: 10_000,
24
+ retry: 1,
25
+ });
26
+ const logs = logsResponse?.data.logs;
27
+
28
+ const preRef = useRef<HTMLPreElement>(null);
29
+
30
+ // Auto-scroll to bottom when new logs arrive
31
+ useEffect(() => {
32
+ if (preRef.current) {
33
+ preRef.current.scrollTop = preRef.current.scrollHeight;
34
+ }
35
+ }, [logs]);
36
+
37
+ return (
38
+ <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
39
+ <div className="relative w-full max-w-4xl rounded-t-xl bg-gray-900 sm:rounded-xl shadow-2xl">
40
+ {/* Header */}
41
+ <div className="flex items-center justify-between border-b border-gray-700 px-4 py-3">
42
+ <div>
43
+ <p className="text-sm font-semibold text-white">{podName}</p>
44
+ <p className="text-xs text-gray-400">namespace: {namespace}{cluster ? ` · cluster: ${cluster}` : ''}</p>
45
+ </div>
46
+ <button
47
+ type="button"
48
+ onClick={onClose}
49
+ className="rounded p-1 text-gray-400 hover:bg-gray-700 hover:text-white"
50
+ aria-label="Close logs"
51
+ >
52
+ <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
53
+ <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
54
+ </svg>
55
+ </button>
56
+ </div>
57
+
58
+ {/* Body */}
59
+ <div className="h-96 overflow-hidden">
60
+ {isPending && (
61
+ <div className="flex h-full items-center justify-center text-gray-400 text-sm">
62
+ Loading logs…
63
+ </div>
64
+ )}
65
+ {isError && (
66
+ <div className="flex h-full items-center justify-center text-red-400 text-sm px-4 text-center">
67
+ {error?.message ?? 'Failed to load logs'}
68
+ </div>
69
+ )}
70
+ {logsResponse !== undefined && (
71
+ <pre
72
+ ref={preRef}
73
+ className="h-full overflow-y-auto p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed"
74
+ style={{ backgroundColor: '#111827', color: '#86efac' }}
75
+ >
76
+ {logs || '(no log output)'}
77
+ </pre>
78
+ )}
79
+ </div>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+
3
+ interface PodStatusBadgeProps {
4
+ status: string;
5
+ }
6
+
7
+ const STATUS_STYLES: Record<string, string> = {
8
+ Running: 'bg-green-100 text-green-800',
9
+ Succeeded: 'bg-green-100 text-green-800',
10
+ Completed: 'bg-green-100 text-green-800',
11
+ Pending: 'bg-yellow-100 text-yellow-800',
12
+ Terminating: 'bg-yellow-100 text-yellow-800',
13
+ Init: 'bg-yellow-100 text-yellow-800',
14
+ CrashLoopBackOff: 'bg-red-100 text-red-800',
15
+ Error: 'bg-red-100 text-red-800',
16
+ OOMKilled: 'bg-red-100 text-red-800',
17
+ Failed: 'bg-red-100 text-red-800',
18
+ ImagePullBackOff: 'bg-red-100 text-red-800',
19
+ Unknown: 'bg-gray-100 text-gray-600',
20
+ };
21
+
22
+ export function PodStatusBadge({ status }: PodStatusBadgeProps): React.ReactElement {
23
+ const key = Object.keys(STATUS_STYLES).find((k) =>
24
+ status.toLowerCase().includes(k.toLowerCase()),
25
+ ) ?? 'Unknown';
26
+
27
+ const cls = STATUS_STYLES[key] ?? STATUS_STYLES['Unknown'];
28
+
29
+ return (
30
+ <span
31
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}
32
+ >
33
+ {status}
34
+ </span>
35
+ );
36
+ }
@@ -0,0 +1,324 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { KubernetesApiClient, parseClusters, resolveCluster } from '../api-client.js';
3
+ import type { ClusterConfig, RawDeployment, RawPod } from '../types.js';
4
+
5
+ // ── Test cluster fixture ──────────────────────────────────────────────────────
6
+
7
+ const CLUSTER: ClusterConfig = {
8
+ name: 'test-cluster',
9
+ url: 'https://k8s.test.internal',
10
+ token: 'test-token',
11
+ skipTLSVerify: false,
12
+ };
13
+
14
+ // ── fetch mock helpers ────────────────────────────────────────────────────────
15
+
16
+ function mockFetchJson(body: unknown, status = 200): void {
17
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
18
+ ok: status >= 200 && status < 300,
19
+ status,
20
+ json: () => Promise.resolve(body),
21
+ text: () => Promise.resolve(JSON.stringify(body)),
22
+ statusText: status === 200 ? 'OK' : 'Error',
23
+ }));
24
+ }
25
+
26
+ function mockFetchText(text: string, status = 200): void {
27
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
28
+ ok: status >= 200 && status < 300,
29
+ status,
30
+ text: () => Promise.resolve(text),
31
+ json: () => Promise.reject(new Error('not JSON')),
32
+ }));
33
+ }
34
+
35
+ beforeEach(() => vi.restoreAllMocks());
36
+ afterEach(() => vi.unstubAllGlobals());
37
+
38
+ // ── parseClusters ─────────────────────────────────────────────────────────────
39
+
40
+ describe('parseClusters', () => {
41
+ it('parses a valid clusters JSON string', () => {
42
+ const json = JSON.stringify([
43
+ { name: 'production', url: 'https://k8s-prod.internal', skipTLSVerify: false },
44
+ { name: 'staging', url: 'https://k8s-stg.internal', skipTLSVerify: true },
45
+ ]);
46
+
47
+ const clusters = parseClusters(json, (key) => `token-${key.toLowerCase()}`);
48
+
49
+ expect(clusters).toHaveLength(2);
50
+ expect(clusters[0]).toMatchObject({
51
+ name: 'production',
52
+ url: 'https://k8s-prod.internal',
53
+ token: 'token-production',
54
+ skipTLSVerify: false,
55
+ });
56
+ expect(clusters[1]).toMatchObject({
57
+ name: 'staging',
58
+ token: 'token-staging',
59
+ skipTLSVerify: true,
60
+ });
61
+ });
62
+
63
+ it('strips trailing slash from cluster URL', () => {
64
+ const json = JSON.stringify([{ name: 'dev', url: 'https://k8s.dev.internal/' }]);
65
+ const clusters = parseClusters(json, () => undefined);
66
+ expect(clusters[0]?.url).toBe('https://k8s.dev.internal');
67
+ });
68
+
69
+ it('defaults skipTLSVerify to false when not provided', () => {
70
+ const json = JSON.stringify([{ name: 'dev', url: 'https://k8s.dev.internal' }]);
71
+ const clusters = parseClusters(json, () => undefined);
72
+ expect(clusters[0]?.skipTLSVerify).toBe(false);
73
+ });
74
+
75
+ it('uses empty string token when env var is not set', () => {
76
+ const json = JSON.stringify([{ name: 'dev', url: 'https://k8s.dev.internal' }]);
77
+ const clusters = parseClusters(json, () => undefined);
78
+ expect(clusters[0]?.token).toBe('');
79
+ });
80
+
81
+ it('throws on invalid JSON', () => {
82
+ expect(() => parseClusters('not-json', () => undefined)).toThrow(/valid JSON/);
83
+ });
84
+ });
85
+
86
+ // ── resolveCluster ────────────────────────────────────────────────────────────
87
+
88
+ describe('resolveCluster', () => {
89
+ const clusters: ClusterConfig[] = [
90
+ { name: 'prod', url: 'https://prod', token: 't1', skipTLSVerify: false },
91
+ { name: 'staging', url: 'https://stg', token: 't2', skipTLSVerify: true },
92
+ ];
93
+
94
+ it('returns first cluster when no name is provided', () => {
95
+ expect(resolveCluster(clusters)).toBe(clusters[0]);
96
+ });
97
+
98
+ it('resolves cluster by name', () => {
99
+ expect(resolveCluster(clusters, 'staging')).toBe(clusters[1]);
100
+ });
101
+
102
+ it('throws when named cluster does not exist', () => {
103
+ expect(() => resolveCluster(clusters, 'ghost')).toThrow(/not found/);
104
+ });
105
+
106
+ it('throws when cluster list is empty', () => {
107
+ expect(() => resolveCluster([])).toThrow(/no clusters configured/);
108
+ });
109
+ });
110
+
111
+ // ── KubernetesApiClient.getWorkloads ──────────────────────────────────────────
112
+
113
+ describe('KubernetesApiClient.getWorkloads', () => {
114
+ const client = new KubernetesApiClient(CLUSTER);
115
+
116
+ const rawDeployment: RawDeployment = {
117
+ metadata: { name: 'payment-api', namespace: 'production' },
118
+ spec: {
119
+ replicas: 3,
120
+ template: { spec: { containers: [{ image: 'registry.io/payment-api:v1.2.3' }] } },
121
+ },
122
+ status: {
123
+ replicas: 3,
124
+ readyReplicas: 3,
125
+ availableReplicas: 3,
126
+ conditions: [{ type: 'Progressing', status: 'True', lastUpdateTime: '2026-02-20T10:00:00Z' }],
127
+ },
128
+ };
129
+
130
+ const rawPod: RawPod = {
131
+ metadata: { name: 'payment-api-abc12', namespace: 'production' },
132
+ spec: { nodeName: 'node-1', containers: [{ name: 'payment-api' }] },
133
+ status: {
134
+ phase: 'Running',
135
+ startTime: '2026-02-20T09:00:00Z',
136
+ containerStatuses: [{ ready: true, state: {} }],
137
+ },
138
+ };
139
+
140
+ it('returns normalised workloads on success', async () => {
141
+ const mockResponse = (url: string) => {
142
+ if (url.includes('/deployments')) return { items: [rawDeployment] };
143
+ if (url.includes('/pods')) return { items: [rawPod] };
144
+ if (url.includes('/services')) return { items: [] };
145
+ return { items: [] };
146
+ };
147
+
148
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
149
+ Promise.resolve({
150
+ ok: true,
151
+ status: 200,
152
+ json: () => Promise.resolve(mockResponse(url)),
153
+ text: () => Promise.resolve(''),
154
+ }),
155
+ ));
156
+
157
+ const result = await client.getWorkloads('production', 'app=payment-api');
158
+
159
+ expect(result.cluster).toBe('test-cluster');
160
+ expect(result.namespace).toBe('production');
161
+ expect(result.labelSelector).toBe('app=payment-api');
162
+
163
+ expect(result.deployments).toHaveLength(1);
164
+ expect(result.deployments[0]).toMatchObject({
165
+ name: 'payment-api',
166
+ healthy: true,
167
+ replicas: { desired: 3, ready: 3, available: 3 },
168
+ image: 'registry.io/payment-api:v1.2.3',
169
+ });
170
+
171
+ expect(result.pods).toHaveLength(1);
172
+ expect(result.pods[0]).toMatchObject({
173
+ name: 'payment-api-abc12',
174
+ status: 'Running',
175
+ ready: true,
176
+ });
177
+ });
178
+
179
+ it('marks deployment unhealthy when readyReplicas < replicas', async () => {
180
+ const unhealthyDeployment: RawDeployment = {
181
+ ...rawDeployment,
182
+ status: { replicas: 3, readyReplicas: 1, availableReplicas: 1 },
183
+ };
184
+
185
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
186
+ Promise.resolve({
187
+ ok: true,
188
+ status: 200,
189
+ json: () => Promise.resolve(url.includes('/deployments')
190
+ ? { items: [unhealthyDeployment] }
191
+ : { items: [] }),
192
+ text: () => Promise.resolve(''),
193
+ }),
194
+ ));
195
+
196
+ const result = await client.getWorkloads('production', 'app=payment-api');
197
+ expect(result.deployments[0]?.healthy).toBe(false);
198
+ });
199
+
200
+ it('detects CrashLoopBackOff pod status', async () => {
201
+ const crashPod: RawPod = {
202
+ ...rawPod,
203
+ status: {
204
+ phase: 'Running',
205
+ containerStatuses: [{ ready: false, state: { waiting: { reason: 'CrashLoopBackOff' } } }],
206
+ },
207
+ };
208
+
209
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
210
+ Promise.resolve({
211
+ ok: true,
212
+ status: 200,
213
+ json: () => Promise.resolve(url.includes('/pods')
214
+ ? { items: [crashPod] }
215
+ : { items: [] }),
216
+ text: () => Promise.resolve(''),
217
+ }),
218
+ ));
219
+
220
+ const result = await client.getWorkloads('production', 'app=crash');
221
+ expect(result.pods[0]?.status).toBe('CrashLoopBackOff');
222
+ expect(result.pods[0]?.ready).toBe(false);
223
+ });
224
+
225
+ it('sends the Authorization header with the Bearer token', async () => {
226
+ const fetchMock = vi.fn().mockResolvedValue({
227
+ ok: true, status: 200,
228
+ json: () => Promise.resolve({ items: [] }),
229
+ text: () => Promise.resolve(''),
230
+ });
231
+ vi.stubGlobal('fetch', fetchMock);
232
+
233
+ await client.getWorkloads('default', 'app=test');
234
+
235
+ const firstCall = fetchMock.mock.calls[0] as [string, RequestInit];
236
+ expect((firstCall[1].headers as Record<string, string>)['Authorization']).toBe('Bearer test-token');
237
+ });
238
+
239
+ it('throws on non-ok response from K8s API', async () => {
240
+ mockFetchJson({ message: 'Forbidden' }, 403);
241
+ await expect(client.getWorkloads('default', 'app=test')).rejects.toThrow('403');
242
+ });
243
+
244
+ it('gracefully handles ingress API failure (returns empty ingresses)', async () => {
245
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
246
+ Promise.resolve({
247
+ ok: !url.includes('/ingresses'),
248
+ status: url.includes('/ingresses') ? 404 : 200,
249
+ json: () => Promise.resolve({ items: [] }),
250
+ text: () => Promise.resolve('404 Not Found'),
251
+ }),
252
+ ));
253
+
254
+ const result = await client.getWorkloads('default', 'app=test');
255
+ expect(result.ingresses).toHaveLength(0);
256
+ });
257
+ });
258
+
259
+ // ── KubernetesApiClient.getPodLogs ────────────────────────────────────────────
260
+
261
+ describe('KubernetesApiClient.getPodLogs', () => {
262
+ const client = new KubernetesApiClient(CLUSTER);
263
+
264
+ it('returns log text on success', async () => {
265
+ const logText = '2026-02-20T10:00:00Z INFO Server started\n2026-02-20T10:00:01Z INFO Listening on :8080';
266
+ mockFetchText(logText);
267
+
268
+ const logs = await client.getPodLogs('default', 'my-pod-abc12');
269
+ expect(logs).toBe(logText);
270
+ });
271
+
272
+ it('requests the correct tailLines parameter', async () => {
273
+ const fetchMock = vi.fn().mockResolvedValue({
274
+ ok: true, status: 200, text: () => Promise.resolve('log line'),
275
+ });
276
+ vi.stubGlobal('fetch', fetchMock);
277
+
278
+ await client.getPodLogs('default', 'my-pod', 200);
279
+
280
+ const url = (fetchMock.mock.calls[0] as [string])[0];
281
+ expect(url).toContain('tailLines=200');
282
+ });
283
+
284
+ it('throws on non-ok response', async () => {
285
+ mockFetchText('Not found', 404);
286
+ await expect(client.getPodLogs('default', 'ghost-pod')).rejects.toThrow('404');
287
+ });
288
+ });
289
+
290
+ // ── KubernetesApiClient.restartDeployment ─────────────────────────────────────
291
+
292
+ describe('KubernetesApiClient.restartDeployment', () => {
293
+ const client = new KubernetesApiClient(CLUSTER);
294
+
295
+ it('sends a PATCH request with strategic merge patch content type', async () => {
296
+ const fetchMock = vi.fn().mockResolvedValue({
297
+ ok: true, status: 200,
298
+ json: () => Promise.resolve({}),
299
+ text: () => Promise.resolve(''),
300
+ });
301
+ vi.stubGlobal('fetch', fetchMock);
302
+
303
+ await client.restartDeployment('production', 'payment-api');
304
+
305
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
306
+ expect(url).toContain('/deployments/payment-api');
307
+ expect(init.method).toBe('PATCH');
308
+ expect((init.headers as Record<string, string>)['Content-Type']).toContain('strategic-merge-patch');
309
+
310
+ const body = JSON.parse(init.body as string) as Record<string, unknown>;
311
+ const annotations = (
312
+ (body as { spec: { template: { metadata: { annotations: Record<string, string> } } } })
313
+ .spec.template.metadata.annotations
314
+ );
315
+ // use direct key access — toHaveProperty interprets '.' as path separator
316
+ expect(annotations['kubectl.kubernetes.io/restartedAt']).toBeDefined();
317
+ expect(typeof annotations['kubectl.kubernetes.io/restartedAt']).toBe('string');
318
+ });
319
+
320
+ it('throws on K8s API error', async () => {
321
+ mockFetchJson({ message: 'Forbidden' }, 403);
322
+ await expect(client.restartDeployment('production', 'payment-api')).rejects.toThrow('403');
323
+ });
324
+ });
package/src/actions.ts ADDED
@@ -0,0 +1,146 @@
1
+ import type { ActionProvider } from '@forgeportal/plugin-sdk';
2
+ import type { ClusterConfig } from './types.js';
3
+ import { KubernetesApiClient, resolveCluster } from './api-client.js';
4
+
5
+ /**
6
+ * kubernetes.restartDeployment@v1
7
+ *
8
+ * Triggers a rolling restart of a named Kubernetes Deployment by patching
9
+ * the pod template annotation `kubectl.kubernetes.io/restartedAt`.
10
+ *
11
+ * Input:
12
+ * - deploymentName: string (required)
13
+ * - namespace: string (optional, defaults to "default")
14
+ * - cluster: string (optional, defaults to first configured cluster)
15
+ */
16
+ export function createRestartDeploymentAction(
17
+ clusters: ClusterConfig[],
18
+ defaultNamespace: string,
19
+ ): ActionProvider {
20
+ return {
21
+ id: 'kubernetes.restartDeployment',
22
+ version: 'v1',
23
+ schema: {
24
+ input: {
25
+ type: 'object',
26
+ required: ['deploymentName'],
27
+ properties: {
28
+ deploymentName: {
29
+ type: 'string',
30
+ title: 'Deployment Name',
31
+ description: 'Name of the Kubernetes Deployment to restart.',
32
+ },
33
+ namespace: {
34
+ type: 'string',
35
+ title: 'Namespace',
36
+ description: 'Kubernetes namespace (default: plugin defaultNamespace or "default").',
37
+ },
38
+ cluster: {
39
+ type: 'string',
40
+ title: 'Cluster',
41
+ description: 'Cluster name from plugin config (default: first configured cluster).',
42
+ },
43
+ },
44
+ },
45
+ output: {
46
+ type: 'object',
47
+ properties: {
48
+ restartedAt: { type: 'string', description: 'ISO timestamp of the restart patch.' },
49
+ },
50
+ },
51
+ },
52
+
53
+ async handler(ctx, input) {
54
+ const deploymentName = input['deploymentName'] as string;
55
+ const namespace = (input['namespace'] as string | undefined) ?? defaultNamespace;
56
+ const clusterName = input['cluster'] as string | undefined;
57
+
58
+ ctx.logger.info(`Restarting deployment "${deploymentName}" in namespace "${namespace}"`);
59
+
60
+ const clusterCfg = resolveCluster(clusters, clusterName);
61
+ const client = new KubernetesApiClient(clusterCfg);
62
+
63
+ await client.restartDeployment(namespace, deploymentName);
64
+
65
+ const restartedAt = new Date().toISOString();
66
+ ctx.logger.info(`Deployment "${deploymentName}" restart patch applied at ${restartedAt}`);
67
+
68
+ return {
69
+ status: 'success',
70
+ outputs: { restartedAt },
71
+ links: [
72
+ {
73
+ title: `View in cluster ${clusterCfg.name}`,
74
+ url: `${clusterCfg.url}/apis/apps/v1/namespaces/${namespace}/deployments/${deploymentName}`,
75
+ },
76
+ ],
77
+ };
78
+ },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * kubernetes.scaleDeployment@v1
84
+ *
85
+ * Scales a Kubernetes Deployment to the desired replica count.
86
+ *
87
+ * Input:
88
+ * - deploymentName: string (required)
89
+ * - replicas: number (required)
90
+ * - namespace: string (optional)
91
+ * - cluster: string (optional)
92
+ */
93
+ export function createScaleDeploymentAction(
94
+ clusters: ClusterConfig[],
95
+ defaultNamespace: string,
96
+ ): ActionProvider {
97
+ return {
98
+ id: 'kubernetes.scaleDeployment',
99
+ version: 'v1',
100
+ schema: {
101
+ input: {
102
+ type: 'object',
103
+ required: ['deploymentName', 'replicas'],
104
+ properties: {
105
+ deploymentName: {
106
+ type: 'string',
107
+ title: 'Deployment Name',
108
+ description: 'Name of the Kubernetes Deployment to scale.',
109
+ },
110
+ replicas: {
111
+ type: 'number',
112
+ title: 'Replicas',
113
+ description: 'Desired number of replicas (0–100).',
114
+ },
115
+ namespace: { type: 'string', title: 'Namespace' },
116
+ cluster: { type: 'string', title: 'Cluster' },
117
+ },
118
+ },
119
+ output: {
120
+ type: 'object',
121
+ properties: {
122
+ replicas: { type: 'number', description: 'Replica count applied.' },
123
+ },
124
+ },
125
+ },
126
+
127
+ async handler(ctx, input) {
128
+ const deploymentName = input['deploymentName'] as string;
129
+ const replicas = input['replicas'] as number;
130
+ const namespace = (input['namespace'] as string | undefined) ?? defaultNamespace;
131
+ const clusterName = input['cluster'] as string | undefined;
132
+
133
+ ctx.logger.info(
134
+ `Scaling deployment "${deploymentName}" to ${replicas} replica(s) in namespace "${namespace}"`,
135
+ );
136
+
137
+ const clusterCfg = resolveCluster(clusters, clusterName);
138
+ const client = new KubernetesApiClient(clusterCfg);
139
+
140
+ await client.scaleDeployment(namespace, deploymentName, replicas);
141
+ ctx.logger.info(`Deployment "${deploymentName}" scaled to ${replicas}.`);
142
+
143
+ return { status: 'success', outputs: { replicas } };
144
+ },
145
+ };
146
+ }