@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.
- package/.turbo/turbo-build.log +4 -0
- package/LICENSE +21 -0
- package/dist/KubernetesTab.d.ts +8 -0
- package/dist/KubernetesTab.d.ts.map +1 -0
- package/dist/KubernetesTab.js +91 -0
- package/dist/KubernetesTab.js.map +1 -0
- package/dist/LogsDrawer.d.ts +11 -0
- package/dist/LogsDrawer.d.ts.map +1 -0
- package/dist/LogsDrawer.js +21 -0
- package/dist/LogsDrawer.js.map +1 -0
- package/dist/PodStatusBadge.d.ts +7 -0
- package/dist/PodStatusBadge.d.ts.map +1 -0
- package/dist/PodStatusBadge.js +21 -0
- package/dist/PodStatusBadge.js.map +1 -0
- package/dist/__tests__/api-client.test.d.ts +2 -0
- package/dist/__tests__/api-client.test.d.ts.map +1 -0
- package/dist/__tests__/api-client.test.js +260 -0
- package/dist/__tests__/api-client.test.js.map +1 -0
- package/dist/actions.d.ts +27 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +124 -0
- package/dist/actions.js.map +1 -0
- package/dist/api-client.d.ts +27 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +193 -0
- package/dist/api-client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/routes.d.ts +14 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +115 -0
- package/dist/routes.js.map +1 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +11 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +18 -0
- package/dist/ui.js.map +1 -0
- package/forgeportal-plugin.json +32 -0
- package/package.json +51 -0
- package/src/KubernetesTab.tsx +391 -0
- package/src/LogsDrawer.tsx +83 -0
- package/src/PodStatusBadge.tsx +36 -0
- package/src/__tests__/api-client.test.ts +324 -0
- package/src/actions.ts +146 -0
- package/src/api-client.ts +248 -0
- package/src/index.ts +46 -0
- package/src/routes.ts +154 -0
- package/src/types.ts +103 -0
- package/src/ui.ts +19 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Agent } from 'undici';
|
|
2
|
+
import type { Dispatcher } from 'undici';
|
|
3
|
+
import type {
|
|
4
|
+
ClusterConfig,
|
|
5
|
+
WorkloadsResponse,
|
|
6
|
+
RawDeployment,
|
|
7
|
+
RawPod,
|
|
8
|
+
RawService,
|
|
9
|
+
RawIngress,
|
|
10
|
+
K8sDeployment,
|
|
11
|
+
K8sPod,
|
|
12
|
+
K8sService,
|
|
13
|
+
K8sIngress,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
|
|
16
|
+
// ─── Cluster config parser ────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse the `clusters` JSON string from plugin config and resolve per-cluster
|
|
20
|
+
* tokens from environment variables.
|
|
21
|
+
*
|
|
22
|
+
* Token env var convention: FORGEPORTAL_PLUGIN_KUBERNETES_<CLUSTER_NAME_UPPER>_TOKEN
|
|
23
|
+
* e.g. cluster "production" → FORGEPORTAL_PLUGIN_KUBERNETES_PRODUCTION_TOKEN
|
|
24
|
+
*/
|
|
25
|
+
export function parseClusters(
|
|
26
|
+
rawClustersJson: string,
|
|
27
|
+
getToken: (envKey: string) => string | undefined,
|
|
28
|
+
): ClusterConfig[] {
|
|
29
|
+
let raw: Array<{ name: string; url: string; skipTLSVerify?: boolean }>;
|
|
30
|
+
try {
|
|
31
|
+
raw = JSON.parse(rawClustersJson) as typeof raw;
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'kubernetes plugin: config.clusters must be a valid JSON string representing an array of {name, url} objects',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return raw.map((c) => {
|
|
39
|
+
const envKey = c.name.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
|
|
40
|
+
return {
|
|
41
|
+
name: c.name,
|
|
42
|
+
url: c.url.replace(/\/$/, ''),
|
|
43
|
+
token: getToken(envKey) ?? '',
|
|
44
|
+
skipTLSVerify: c.skipTLSVerify ?? false,
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Mappers ─────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function mapDeployment(d: RawDeployment): K8sDeployment {
|
|
52
|
+
const desired = d.spec.replicas ?? 1;
|
|
53
|
+
const ready = d.status?.readyReplicas ?? 0;
|
|
54
|
+
const available = d.status?.availableReplicas ?? 0;
|
|
55
|
+
const image = d.spec.template.spec.containers[0]?.image ?? '';
|
|
56
|
+
|
|
57
|
+
const rolloutCondition = d.status?.conditions?.find((c) => c.type === 'Progressing');
|
|
58
|
+
const lastRollout = rolloutCondition?.lastUpdateTime ?? d.metadata.creationTimestamp ?? null;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name: d.metadata.name,
|
|
62
|
+
namespace: d.metadata.namespace,
|
|
63
|
+
replicas: { desired, ready, available },
|
|
64
|
+
image,
|
|
65
|
+
lastRollout,
|
|
66
|
+
healthy: ready >= desired && desired > 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolvePodStatus(pod: RawPod): string {
|
|
71
|
+
// Check for CrashLoopBackOff in any container's waiting state
|
|
72
|
+
for (const cs of pod.status?.containerStatuses ?? []) {
|
|
73
|
+
if (cs.state?.waiting?.reason === 'CrashLoopBackOff') return 'CrashLoopBackOff';
|
|
74
|
+
}
|
|
75
|
+
return pod.status?.phase ?? 'Unknown';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mapPod(p: RawPod): K8sPod {
|
|
79
|
+
const containerStatuses = p.status?.containerStatuses ?? [];
|
|
80
|
+
const ready = containerStatuses.length > 0 && containerStatuses.every((cs) => cs.ready);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name: p.metadata.name,
|
|
84
|
+
namespace: p.metadata.namespace,
|
|
85
|
+
status: resolvePodStatus(p),
|
|
86
|
+
ready,
|
|
87
|
+
containers: p.spec?.containers?.length ?? containerStatuses.length,
|
|
88
|
+
nodeName: p.spec?.nodeName ?? null,
|
|
89
|
+
startTime: p.status?.startTime ?? null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mapService(s: RawService): K8sService {
|
|
94
|
+
return {
|
|
95
|
+
name: s.metadata.name,
|
|
96
|
+
namespace: s.metadata.namespace,
|
|
97
|
+
type: s.spec?.type ?? 'ClusterIP',
|
|
98
|
+
clusterIp: s.spec?.clusterIP ?? '',
|
|
99
|
+
ports: (s.spec?.ports ?? []).map((p) => ({
|
|
100
|
+
port: p.port,
|
|
101
|
+
protocol: p.protocol ?? 'TCP',
|
|
102
|
+
targetPort: p.targetPort ?? p.port,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function mapIngress(i: RawIngress): K8sIngress {
|
|
108
|
+
const hosts = (i.spec?.rules ?? [])
|
|
109
|
+
.map((r) => r.host ?? '')
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: i.metadata.name,
|
|
114
|
+
namespace: i.metadata.namespace,
|
|
115
|
+
hosts,
|
|
116
|
+
tls: (i.spec?.tls?.length ?? 0) > 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Client ───────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export class KubernetesApiClient {
|
|
123
|
+
private readonly dispatcher: Dispatcher | undefined;
|
|
124
|
+
|
|
125
|
+
constructor(private readonly cluster: ClusterConfig) {
|
|
126
|
+
if (cluster.skipTLSVerify) {
|
|
127
|
+
this.dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
132
|
+
const url = `${this.cluster.url}${path}`;
|
|
133
|
+
const options: Record<string, unknown> = {
|
|
134
|
+
...(init ?? {}),
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${this.cluster.token}`,
|
|
137
|
+
Accept: 'application/json',
|
|
138
|
+
...((init?.headers as Record<string, string>) ?? {}),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
if (this.dispatcher) options['dispatcher'] = this.dispatcher;
|
|
142
|
+
|
|
143
|
+
const res = await (fetch as (url: string, init: Record<string, unknown>) => Promise<Response>)(url, options);
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const body = await res.text().catch(() => res.statusText);
|
|
146
|
+
throw new Error(`Kubernetes API [${this.cluster.name}] ${path} → ${res.status}: ${body}`);
|
|
147
|
+
}
|
|
148
|
+
return res.json() as Promise<T>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Aggregate Deployments, Pods, Services, and Ingresses for the given label selector.
|
|
153
|
+
*/
|
|
154
|
+
async getWorkloads(namespace: string, labelSelector: string): Promise<WorkloadsResponse> {
|
|
155
|
+
const qs = `labelSelector=${encodeURIComponent(labelSelector)}&limit=100`;
|
|
156
|
+
|
|
157
|
+
const [deployments, pods, services, ingresses] = await Promise.all([
|
|
158
|
+
this.request<{ items: RawDeployment[] }>(
|
|
159
|
+
`/apis/apps/v1/namespaces/${namespace}/deployments?${qs}`,
|
|
160
|
+
),
|
|
161
|
+
this.request<{ items: RawPod[] }>(
|
|
162
|
+
`/api/v1/namespaces/${namespace}/pods?${qs}`,
|
|
163
|
+
),
|
|
164
|
+
this.request<{ items: RawService[] }>(
|
|
165
|
+
`/api/v1/namespaces/${namespace}/services?${qs}`,
|
|
166
|
+
),
|
|
167
|
+
this.request<{ items: RawIngress[] }>(
|
|
168
|
+
`/apis/networking.k8s.io/v1/namespaces/${namespace}/ingresses?${qs}`,
|
|
169
|
+
).catch(() => ({ items: [] as RawIngress[] })),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
cluster: this.cluster.name,
|
|
174
|
+
namespace,
|
|
175
|
+
labelSelector,
|
|
176
|
+
deployments: deployments.items.map(mapDeployment),
|
|
177
|
+
pods: pods.items.map(mapPod),
|
|
178
|
+
services: services.items.map(mapService),
|
|
179
|
+
ingresses: ingresses.items.map(mapIngress),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Returns the last N log lines for a pod container as a plain string. */
|
|
184
|
+
async getPodLogs(namespace: string, podName: string, tailLines = 100): Promise<string> {
|
|
185
|
+
const path = `/api/v1/namespaces/${namespace}/pods/${podName}/log?tailLines=${tailLines}×tamps=true`;
|
|
186
|
+
const options: Record<string, unknown> = {
|
|
187
|
+
headers: { Authorization: `Bearer ${this.cluster.token}` },
|
|
188
|
+
};
|
|
189
|
+
if (this.dispatcher) options['dispatcher'] = this.dispatcher;
|
|
190
|
+
|
|
191
|
+
const res = await (fetch as (url: string, init: Record<string, unknown>) => Promise<Response>)(`${this.cluster.url}${path}`, options);
|
|
192
|
+
if (!res.ok) throw new Error(`Pod logs [${podName}]: ${res.status}`);
|
|
193
|
+
return res.text();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Trigger a rolling restart via a strategic merge patch on the pod template annotation. */
|
|
197
|
+
async restartDeployment(namespace: string, deploymentName: string): Promise<void> {
|
|
198
|
+
const patch = {
|
|
199
|
+
spec: {
|
|
200
|
+
template: {
|
|
201
|
+
metadata: {
|
|
202
|
+
annotations: { 'kubectl.kubernetes.io/restartedAt': new Date().toISOString() },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
await this.request<unknown>(
|
|
208
|
+
`/apis/apps/v1/namespaces/${namespace}/deployments/${deploymentName}`,
|
|
209
|
+
{
|
|
210
|
+
method: 'PATCH',
|
|
211
|
+
headers: { 'Content-Type': 'application/strategic-merge-patch+json' },
|
|
212
|
+
body: JSON.stringify(patch),
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Scale a deployment to the desired replica count. */
|
|
218
|
+
async scaleDeployment(namespace: string, deploymentName: string, replicas: number): Promise<void> {
|
|
219
|
+
await this.request<unknown>(
|
|
220
|
+
`/apis/apps/v1/namespaces/${namespace}/deployments/${deploymentName}/scale`,
|
|
221
|
+
{
|
|
222
|
+
method: 'PUT',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ spec: { replicas } }),
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Cluster resolver ─────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export function resolveCluster(
|
|
233
|
+
clusters: ClusterConfig[],
|
|
234
|
+
clusterName?: string,
|
|
235
|
+
): ClusterConfig {
|
|
236
|
+
if (!clusterName) {
|
|
237
|
+
const first = clusters[0];
|
|
238
|
+
if (!first) throw new Error('kubernetes plugin: no clusters configured');
|
|
239
|
+
return first;
|
|
240
|
+
}
|
|
241
|
+
const found = clusters.find((c) => c.name === clusterName);
|
|
242
|
+
if (!found) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`kubernetes plugin: cluster "${clusterName}" not found. Available: ${clusters.map((c) => c.name).join(', ')}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return found;
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ForgeBackendPluginSDK } from '@forgeportal/plugin-sdk';
|
|
2
|
+
import { parseClusters } from './api-client.js';
|
|
3
|
+
import { createRoutes } from './routes.js';
|
|
4
|
+
import { createRestartDeploymentAction, createScaleDeploymentAction } from './actions.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backend entry point for the Kubernetes plugin.
|
|
8
|
+
* Called by the ForgePortal plugin loader at startup.
|
|
9
|
+
*
|
|
10
|
+
* Configuration (forgeportal.yaml → plugins.kubernetes.config):
|
|
11
|
+
* clusters: JSON string — array of {name, url, skipTLSVerify?}
|
|
12
|
+
* defaultNamespace: string (default: "default")
|
|
13
|
+
*
|
|
14
|
+
* Per-cluster tokens come from env:
|
|
15
|
+
* FORGEPORTAL_PLUGIN_KUBERNETES_<CLUSTER_NAME_UPPER>_TOKEN
|
|
16
|
+
*/
|
|
17
|
+
export function registerBackendPlugin(sdk: ForgeBackendPluginSDK): void {
|
|
18
|
+
const rawClusters = sdk.config.get<string>('clusters') ?? '[]';
|
|
19
|
+
const defaultNs = sdk.config.get<string>('defaultNamespace') ?? 'default';
|
|
20
|
+
|
|
21
|
+
const clusters = parseClusters(
|
|
22
|
+
rawClusters,
|
|
23
|
+
(envKey) => process.env[`FORGEPORTAL_PLUGIN_KUBERNETES_${envKey}_TOKEN`],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (clusters.length === 0) {
|
|
27
|
+
sdk.logger.warn(
|
|
28
|
+
'kubernetes plugin: no clusters configured. ' +
|
|
29
|
+
'Set plugins.kubernetes.config.clusters in forgeportal.yaml.',
|
|
30
|
+
);
|
|
31
|
+
} else {
|
|
32
|
+
sdk.logger.info(
|
|
33
|
+
`kubernetes plugin: ${clusters.length} cluster(s) configured — ${clusters.map((c) => c.name).join(', ')}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Mount backend routes under /api/v1/plugins/kubernetes/
|
|
38
|
+
sdk.registerBackendRoute({
|
|
39
|
+
path: '',
|
|
40
|
+
handler: createRoutes(clusters, defaultNs),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Register template-usable action providers
|
|
44
|
+
sdk.registerActionProvider(createRestartDeploymentAction(clusters, defaultNs));
|
|
45
|
+
sdk.registerActionProvider(createScaleDeploymentAction(clusters, defaultNs));
|
|
46
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import type { ClusterConfig } from './types.js';
|
|
3
|
+
import { KubernetesApiClient, resolveCluster } from './api-client.js';
|
|
4
|
+
|
|
5
|
+
interface WorkloadsQuery { labelSelector?: string; namespace?: string; cluster?: string }
|
|
6
|
+
interface WorkloadsParams { entityId: string }
|
|
7
|
+
|
|
8
|
+
interface LogsQuery { namespace?: string; cluster?: string; tail?: string }
|
|
9
|
+
interface LogsParams { entityId: string; podName: string }
|
|
10
|
+
|
|
11
|
+
interface RestartParams { entityId: string; deploymentName: string }
|
|
12
|
+
interface RestartBody { namespace?: string; cluster?: string }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates Fastify route handlers for the Kubernetes plugin.
|
|
16
|
+
* All routes are mounted under /api/v1/plugins/kubernetes/ by the plugin loader.
|
|
17
|
+
*
|
|
18
|
+
* Routes:
|
|
19
|
+
* GET clusters
|
|
20
|
+
* GET entities/:entityId/workloads
|
|
21
|
+
* GET entities/:entityId/pods/:podName/logs
|
|
22
|
+
* POST entities/:entityId/deployments/:deploymentName/restart
|
|
23
|
+
*/
|
|
24
|
+
export function createRoutes(
|
|
25
|
+
clusters: ClusterConfig[],
|
|
26
|
+
defaultNamespace: string,
|
|
27
|
+
) {
|
|
28
|
+
return async function handler(fastify: FastifyInstance): Promise<void> {
|
|
29
|
+
/**
|
|
30
|
+
* GET /clusters
|
|
31
|
+
*
|
|
32
|
+
* Returns the list of configured cluster names and URLs (no tokens).
|
|
33
|
+
*/
|
|
34
|
+
fastify.get('clusters', async (_request: FastifyRequest, reply: FastifyReply) => {
|
|
35
|
+
return reply.send({
|
|
36
|
+
data: clusters.map((c) => ({
|
|
37
|
+
name: c.name,
|
|
38
|
+
url: c.url,
|
|
39
|
+
skipTLSVerify: c.skipTLSVerify,
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* GET /entities/:entityId/workloads
|
|
46
|
+
*
|
|
47
|
+
* Query params:
|
|
48
|
+
* labelSelector (required) — K8s label selector, e.g. "app=payment-api"
|
|
49
|
+
* namespace (optional) — K8s namespace, defaults to plugin defaultNamespace
|
|
50
|
+
* cluster (optional) — cluster name, defaults to first configured cluster
|
|
51
|
+
*/
|
|
52
|
+
fastify.get(
|
|
53
|
+
'entities/:entityId/workloads',
|
|
54
|
+
async (
|
|
55
|
+
request: FastifyRequest<{ Params: WorkloadsParams; Querystring: WorkloadsQuery }>,
|
|
56
|
+
reply: FastifyReply,
|
|
57
|
+
) => {
|
|
58
|
+
const { labelSelector, namespace: ns, cluster: clusterName } = request.query;
|
|
59
|
+
|
|
60
|
+
if (!labelSelector) {
|
|
61
|
+
return reply.status(400).send({
|
|
62
|
+
error: 'Bad Request',
|
|
63
|
+
message: 'Query parameter "labelSelector" is required.',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const namespace = ns ?? defaultNamespace;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const clusterCfg = resolveCluster(clusters, clusterName);
|
|
71
|
+
const client = new KubernetesApiClient(clusterCfg);
|
|
72
|
+
const workloads = await client.getWorkloads(namespace, labelSelector);
|
|
73
|
+
return reply.send({ data: workloads });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
request.log.error({ err }, 'kubernetes plugin: getWorkloads failed');
|
|
77
|
+
if (message.includes('not found')) {
|
|
78
|
+
return reply.status(404).send({ error: 'Not Found', message });
|
|
79
|
+
}
|
|
80
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* GET /entities/:entityId/pods/:podName/logs
|
|
87
|
+
*
|
|
88
|
+
* Query params:
|
|
89
|
+
* namespace (optional)
|
|
90
|
+
* cluster (optional)
|
|
91
|
+
* tail (optional, default 100) — number of log lines to return
|
|
92
|
+
*/
|
|
93
|
+
fastify.get(
|
|
94
|
+
'entities/:entityId/pods/:podName/logs',
|
|
95
|
+
async (
|
|
96
|
+
request: FastifyRequest<{ Params: LogsParams; Querystring: LogsQuery }>,
|
|
97
|
+
reply: FastifyReply,
|
|
98
|
+
) => {
|
|
99
|
+
const { podName } = request.params;
|
|
100
|
+
const { namespace: ns, cluster: clusterName, tail } = request.query;
|
|
101
|
+
|
|
102
|
+
const namespace = ns ?? defaultNamespace;
|
|
103
|
+
const tailLines = tail ? parseInt(tail, 10) : 100;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const clusterCfg = resolveCluster(clusters, clusterName);
|
|
107
|
+
const client = new KubernetesApiClient(clusterCfg);
|
|
108
|
+
const logs = await client.getPodLogs(namespace, podName, tailLines);
|
|
109
|
+
return reply.send({ data: { logs } });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
request.log.error({ err }, 'kubernetes plugin: getPodLogs failed');
|
|
113
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* POST /entities/:entityId/deployments/:deploymentName/restart
|
|
120
|
+
*
|
|
121
|
+
* Body: { namespace?: string; cluster?: string }
|
|
122
|
+
*/
|
|
123
|
+
fastify.post(
|
|
124
|
+
'entities/:entityId/deployments/:deploymentName/restart',
|
|
125
|
+
async (
|
|
126
|
+
request: FastifyRequest<{ Params: RestartParams; Body: RestartBody }>,
|
|
127
|
+
reply: FastifyReply,
|
|
128
|
+
) => {
|
|
129
|
+
const { deploymentName } = request.params;
|
|
130
|
+
const { namespace: ns, cluster: clusterName } = request.body ?? {};
|
|
131
|
+
|
|
132
|
+
const namespace = ns ?? defaultNamespace;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const clusterCfg = resolveCluster(clusters, clusterName);
|
|
136
|
+
const client = new KubernetesApiClient(clusterCfg);
|
|
137
|
+
await client.restartDeployment(namespace, deploymentName);
|
|
138
|
+
return reply.status(202).send({
|
|
139
|
+
data: {
|
|
140
|
+
deploymentName,
|
|
141
|
+
namespace,
|
|
142
|
+
cluster: clusterCfg.name,
|
|
143
|
+
restartedAt: new Date().toISOString(),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
148
|
+
request.log.error({ err }, 'kubernetes plugin: restartDeployment failed');
|
|
149
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// ─── Cluster config ──────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface ClusterConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
url: string;
|
|
6
|
+
/** Service-account token. Sourced from FORGEPORTAL_PLUGIN_KUBERNETES_<NAME>_TOKEN. */
|
|
7
|
+
token: string;
|
|
8
|
+
skipTLSVerify: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ─── Normalised K8s resource types returned by the plugin API ────────────────
|
|
12
|
+
|
|
13
|
+
export interface K8sDeployment {
|
|
14
|
+
name: string;
|
|
15
|
+
namespace: string;
|
|
16
|
+
replicas: { desired: number; ready: number; available: number };
|
|
17
|
+
image: string;
|
|
18
|
+
lastRollout: string | null;
|
|
19
|
+
healthy: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface K8sPod {
|
|
23
|
+
name: string;
|
|
24
|
+
namespace: string;
|
|
25
|
+
/** e.g. "Running" | "Pending" | "CrashLoopBackOff" | "Completed" */
|
|
26
|
+
status: string;
|
|
27
|
+
ready: boolean;
|
|
28
|
+
containers: number;
|
|
29
|
+
nodeName: string | null;
|
|
30
|
+
startTime: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface K8sService {
|
|
34
|
+
name: string;
|
|
35
|
+
namespace: string;
|
|
36
|
+
type: string;
|
|
37
|
+
clusterIp: string;
|
|
38
|
+
ports: Array<{ port: number; protocol: string; targetPort: string | number }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface K8sIngress {
|
|
42
|
+
name: string;
|
|
43
|
+
namespace: string;
|
|
44
|
+
hosts: string[];
|
|
45
|
+
tls: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WorkloadsResponse {
|
|
49
|
+
cluster: string;
|
|
50
|
+
namespace: string;
|
|
51
|
+
labelSelector: string;
|
|
52
|
+
deployments: K8sDeployment[];
|
|
53
|
+
pods: K8sPod[];
|
|
54
|
+
services: K8sService[];
|
|
55
|
+
ingresses: K8sIngress[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Raw K8s API shapes (minimal subset) ─────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export interface RawDeployment {
|
|
61
|
+
metadata: { name: string; namespace: string; creationTimestamp?: string };
|
|
62
|
+
spec: {
|
|
63
|
+
replicas?: number;
|
|
64
|
+
template: { spec: { containers: Array<{ image?: string }> } };
|
|
65
|
+
};
|
|
66
|
+
status?: {
|
|
67
|
+
replicas?: number;
|
|
68
|
+
readyReplicas?: number;
|
|
69
|
+
availableReplicas?: number;
|
|
70
|
+
conditions?: Array<{ type: string; status: string; lastUpdateTime?: string }>;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RawPod {
|
|
75
|
+
metadata: { name: string; namespace: string };
|
|
76
|
+
spec?: { nodeName?: string; containers?: Array<{ name: string }> };
|
|
77
|
+
status?: {
|
|
78
|
+
phase?: string;
|
|
79
|
+
startTime?: string;
|
|
80
|
+
conditions?: Array<{ type: string; status: string }>;
|
|
81
|
+
containerStatuses?: Array<{
|
|
82
|
+
ready?: boolean;
|
|
83
|
+
state?: { waiting?: { reason?: string } };
|
|
84
|
+
}>;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RawService {
|
|
89
|
+
metadata: { name: string; namespace: string };
|
|
90
|
+
spec?: {
|
|
91
|
+
type?: string;
|
|
92
|
+
clusterIP?: string;
|
|
93
|
+
ports?: Array<{ port: number; protocol?: string; targetPort?: string | number }>;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface RawIngress {
|
|
98
|
+
metadata: { name: string; namespace: string };
|
|
99
|
+
spec?: {
|
|
100
|
+
tls?: unknown[];
|
|
101
|
+
rules?: Array<{ host?: string }>;
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ForgePluginSDK } from '@forgeportal/plugin-sdk';
|
|
2
|
+
import { KubernetesTab } from './KubernetesTab.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UI entry point for the Kubernetes plugin.
|
|
6
|
+
* Called by the ForgePortal UI shell at startup.
|
|
7
|
+
*
|
|
8
|
+
* Registration in apps/ui/src/plugins/index.ts:
|
|
9
|
+
* import { registerPlugin as registerKubernetes } from '@forgeportal/plugin-kubernetes/ui';
|
|
10
|
+
* registerPluginById('kubernetes', registerKubernetes);
|
|
11
|
+
*/
|
|
12
|
+
export function registerPlugin(sdk: ForgePluginSDK): void {
|
|
13
|
+
sdk.registerEntityTab({
|
|
14
|
+
id: 'kubernetes-tab',
|
|
15
|
+
title: 'Kubernetes',
|
|
16
|
+
component: KubernetesTab,
|
|
17
|
+
// No appliesTo restriction — the tab component handles missing annotation gracefully
|
|
18
|
+
});
|
|
19
|
+
}
|