feat: topology, and improves handling

This commit is contained in:
2026-02-17 18:12:02 +07:00
parent e8dbefde43
commit d14f043e7c
145 changed files with 4213 additions and 2861 deletions

View File

@@ -0,0 +1,40 @@
import { getErrorMessage } from "@minikura/shared/errors";
import { logger } from "../../../infrastructure/logger";
export abstract class BaseK8sOperations {
protected namespace: string;
constructor(namespace: string) {
this.namespace = namespace;
}
protected async executeOperation<T>(
operation: () => Promise<T>,
errorContext: string
): Promise<T> {
try {
return await operation();
} catch (error: unknown) {
const errorMessage = getErrorMessage(error);
logger.error({ err: error, context: errorContext }, "K8s operation failed");
throw new Error(`${errorContext}: ${errorMessage}`);
}
}
protected async executeOperationSafe<T>(
operation: () => Promise<T>,
defaultValue: T,
errorContext: string
): Promise<T> {
try {
return await operation();
} catch (error: unknown) {
logger.error({ err: error, context: errorContext }, "K8s operation failed (safe mode)");
return defaultValue;
}
}
getNamespace(): string {
return this.namespace;
}
}

View File

@@ -0,0 +1,39 @@
import type * as k8s from "@kubernetes/client-node";
import { BaseK8sOperations } from "./base.operations";
export class ClusterOperations extends BaseK8sOperations {
constructor(
private coreApi: k8s.CoreV1Api,
private customObjectsApi: k8s.CustomObjectsApi,
namespace: string
) {
super(namespace);
}
async listNodes() {
return this.executeOperation(
() => this.coreApi.listNode().then((r) => r.items),
"Failed to fetch nodes"
);
}
async getNodeMetrics() {
return this.executeOperation(
() =>
this.customObjectsApi.listClusterCustomObject({
group: "metrics.k8s.io",
version: "v1beta1",
plural: "nodes",
}),
"Failed to fetch node metrics"
);
}
async listConfigMaps(namespace?: string) {
const ns = namespace || this.namespace;
return this.executeOperation(
() => this.coreApi.listNamespacedConfigMap({ namespace: ns }).then((r) => r.items),
"Failed to fetch configmaps"
);
}
}

View File

@@ -0,0 +1,55 @@
import type * as k8s from "@kubernetes/client-node";
import type { CustomResourceSummary } from "@minikura/api";
import { getAge } from "@minikura/shared/errors";
import { BaseK8sOperations } from "./base.operations";
interface CustomResourceItem {
kind?: string;
metadata?: {
name?: string;
namespace?: string;
creationTimestamp?: string;
labels?: Record<string, string>;
};
spec?: Record<string, unknown>;
status?: { phase?: string; [key: string]: unknown };
}
interface CustomResourceList {
items?: CustomResourceItem[];
}
export class CustomResourceOperations extends BaseK8sOperations {
constructor(
private customObjectsApi: k8s.CustomObjectsApi,
namespace: string
) {
super(namespace);
}
async listCustomResources(
group: string,
version: string,
plural: string
): Promise<CustomResourceSummary[]> {
return this.executeOperation(async () => {
const response = await this.customObjectsApi.listNamespacedCustomObject({
group,
version,
namespace: this.namespace,
plural,
});
const items = (response as unknown as CustomResourceList).items || [];
return items.map((item) => ({
name: item.metadata?.name ?? "",
namespace: item.metadata?.namespace ?? this.namespace,
age: getAge(item.metadata?.creationTimestamp),
labels: item.metadata?.labels,
spec: item.spec ?? {},
status: item.status ?? {},
}));
}, `Failed to fetch custom resources (${plural})`);
}
}

View File

@@ -0,0 +1,6 @@
export { BaseK8sOperations } from "./base.operations";
export { ClusterOperations } from "./cluster.operations";
export { CustomResourceOperations } from "./custom-resource.operations";
export { NetworkOperations } from "./network.operations";
export { PodOperations } from "./pod.operations";
export { WorkloadOperations } from "./workload.operations";

View File

@@ -0,0 +1,89 @@
import type * as k8s from "@kubernetes/client-node";
import { BaseK8sOperations } from "./base.operations";
export class NetworkOperations extends BaseK8sOperations {
constructor(
private coreApi: k8s.CoreV1Api,
private networkingApi: k8s.NetworkingV1Api,
namespace: string
) {
super(namespace);
}
async listServices() {
return this.executeOperation(
() => this.coreApi.listNamespacedService({ namespace: this.namespace }).then((r) => r.items),
"Failed to fetch services"
);
}
async listIngresses() {
return this.executeOperation(
() =>
this.networkingApi
.listNamespacedIngress({ namespace: this.namespace })
.then((r) => r.items),
"Failed to fetch ingresses"
);
}
async getServiceInfo(serviceName: string) {
return this.executeOperation(
() => this.coreApi.readNamespacedService({ name: serviceName, namespace: this.namespace }),
`Failed to fetch service info for ${serviceName}`
);
}
async getServerConnectionInfo(serviceName: string) {
return this.executeOperation(async () => {
const service = await this.coreApi.readNamespacedService({
name: serviceName,
namespace: this.namespace,
});
const serviceType = service.spec?.type || "ClusterIP";
const ports = service.spec?.ports || [];
let host: string;
let externalHost: string | undefined;
switch (serviceType) {
case "LoadBalancer": {
const ingress = service.status?.loadBalancer?.ingress?.[0];
host =
ingress?.hostname ||
ingress?.ip ||
`${serviceName}.${this.namespace}.svc.cluster.local`;
externalHost = ingress?.hostname || ingress?.ip;
break;
}
case "NodePort":
host = `${serviceName}.${this.namespace}.svc.cluster.local`;
externalHost = "<node-ip>";
break;
default:
host = `${serviceName}.${this.namespace}.svc.cluster.local`;
externalHost = undefined;
}
const portMappings = ports.map((port) => ({
name: port.name,
port: port.port,
targetPort: port.targetPort,
nodePort: port.nodePort,
protocol: port.protocol || "TCP",
}));
return {
serviceName,
namespace: this.namespace,
serviceType,
internalHost: host,
externalHost,
ports: portMappings,
};
}, `Failed to fetch connection info for service ${serviceName}`);
}
}

View File

@@ -0,0 +1,72 @@
import type * as k8s from "@kubernetes/client-node";
import { BaseK8sOperations } from "./base.operations";
export class PodOperations extends BaseK8sOperations {
constructor(
private coreApi: k8s.CoreV1Api,
namespace: string
) {
super(namespace);
}
async listPods() {
return this.executeOperation(
() => this.coreApi.listNamespacedPod({ namespace: this.namespace }).then((r) => r.items),
"Failed to fetch pods"
);
}
async listPodsByLabel(labelSelector: string) {
return this.executeOperation(
() =>
this.coreApi
.listNamespacedPod({ namespace: this.namespace, labelSelector })
.then((r) => r.items),
"Failed to fetch pods by label"
);
}
async getPodInfo(podName: string) {
return this.executeOperation(
() => this.coreApi.readNamespacedPod({ name: podName, namespace: this.namespace }),
`Failed to fetch pod info for ${podName}`
);
}
async getPodLogs(
podName: string,
options?: {
container?: string;
tailLines?: number;
timestamps?: boolean;
sinceSeconds?: number;
}
): Promise<string> {
return this.executeOperation(
() =>
this.coreApi.readNamespacedPodLog({
name: podName,
namespace: this.namespace,
container: options?.container,
tailLines: options?.tailLines,
timestamps: options?.timestamps,
sinceSeconds: options?.sinceSeconds,
}),
`Failed to fetch logs for pod ${podName}`
);
}
async getPodMetrics(customObjectsApi: k8s.CustomObjectsApi, namespace?: string) {
const ns = namespace || this.namespace;
return this.executeOperation(
() =>
customObjectsApi.listNamespacedCustomObject({
group: "metrics.k8s.io",
version: "v1beta1",
namespace: ns,
plural: "pods",
}),
"Failed to fetch pod metrics"
);
}
}

View File

@@ -0,0 +1,27 @@
import type * as k8s from "@kubernetes/client-node";
import { BaseK8sOperations } from "./base.operations";
export class WorkloadOperations extends BaseK8sOperations {
constructor(
private appsApi: k8s.AppsV1Api,
namespace: string
) {
super(namespace);
}
async listDeployments() {
return this.executeOperation(
() =>
this.appsApi.listNamespacedDeployment({ namespace: this.namespace }).then((r) => r.items),
"Failed to fetch deployments"
);
}
async listStatefulSets() {
return this.executeOperation(
() =>
this.appsApi.listNamespacedStatefulSet({ namespace: this.namespace }).then((r) => r.items),
"Failed to fetch statefulsets"
);
}
}

View File

@@ -0,0 +1,257 @@
import type * as k8s from "@kubernetes/client-node";
import type {
DeploymentInfo,
K8sConfigMapSummary,
K8sIngressSummary,
K8sNodeSummary,
K8sServiceInfo,
K8sServicePort,
K8sServiceSummary,
PodDetails,
PodInfo,
StatefulSetInfo,
} from "@minikura/api";
import { getAge } from "@minikura/shared/errors";
export class K8sResources {
constructor(
private readonly coreApi: k8s.CoreV1Api,
private readonly appsApi: k8s.AppsV1Api,
private readonly networkingApi: k8s.NetworkingV1Api,
private readonly namespace: string
) {}
async listPods(): Promise<PodInfo[]> {
const response = await this.coreApi.listNamespacedPod({ namespace: this.namespace });
return response.items.map((pod) => mapPodInfo(pod));
}
async listPodsByLabel(labelSelector: string): Promise<PodInfo[]> {
const response = await this.coreApi.listNamespacedPod({
namespace: this.namespace,
labelSelector,
});
return response.items.map((pod) => ({
...mapPodInfo(pod),
containers: pod.spec?.containers?.map((container) => container.name ?? "") || [],
}));
}
async getPodInfo(podName: string): Promise<PodDetails> {
const response = await this.coreApi.readNamespacedPod({
name: podName,
namespace: this.namespace,
});
const pod = response;
return {
...mapPodInfo(pod),
containers: pod.spec?.containers?.map((container) => container.name ?? "") || [],
ip: pod.status?.podIP,
conditions:
pod.status?.conditions?.map((condition) => ({
type: condition.type,
status: condition.status,
lastTransitionTime: condition.lastTransitionTime
? condition.lastTransitionTime.toISOString()
: undefined,
})) || [],
containerStatuses:
pod.status?.containerStatuses?.map((status) => ({
name: status.name,
ready: status.ready,
restartCount: status.restartCount,
state: status.state
? {
waiting: status.state.waiting
? {
reason: status.state.waiting.reason,
message: status.state.waiting.message,
}
: undefined,
running: status.state.running
? {
startedAt: status.state.running.startedAt,
}
: undefined,
terminated: status.state.terminated
? {
reason: status.state.terminated.reason,
exitCode: status.state.terminated.exitCode,
finishedAt: status.state.terminated.finishedAt,
}
: undefined,
}
: undefined,
})) || [],
};
}
async listDeployments(): Promise<DeploymentInfo[]> {
const response = await this.appsApi.listNamespacedDeployment({ namespace: this.namespace });
return response.items.map((deployment) => ({
name: deployment.metadata?.name ?? "",
namespace: deployment.metadata?.namespace,
ready: `${deployment.status?.readyReplicas ?? 0}/${deployment.status?.replicas ?? 0}`,
desired: deployment.status?.replicas ?? 0,
current: deployment.status?.replicas ?? 0,
updated: deployment.status?.updatedReplicas ?? 0,
upToDate: deployment.status?.updatedReplicas ?? 0,
available: deployment.status?.availableReplicas ?? 0,
age: getAge(deployment.metadata?.creationTimestamp),
labels: deployment.metadata?.labels,
}));
}
async listStatefulSets(): Promise<StatefulSetInfo[]> {
const response = await this.appsApi.listNamespacedStatefulSet({ namespace: this.namespace });
return response.items.map((statefulSet) => ({
name: statefulSet.metadata?.name ?? "",
namespace: statefulSet.metadata?.namespace,
ready: `${statefulSet.status?.readyReplicas ?? 0}/${statefulSet.spec?.replicas ?? 0}`,
desired: statefulSet.spec?.replicas ?? 0,
current: statefulSet.status?.currentReplicas ?? 0,
updated: statefulSet.status?.updatedReplicas ?? 0,
age: getAge(statefulSet.metadata?.creationTimestamp),
labels: statefulSet.metadata?.labels,
}));
}
async listServices(): Promise<K8sServiceSummary[]> {
const response = await this.coreApi.listNamespacedService({ namespace: this.namespace });
return response.items.map((service) => {
const ports = service.spec?.ports ?? [];
const portSummary = ports
.map((port) => `${port.port}${port.nodePort ? `:${port.nodePort}` : ""}/${port.protocol}`)
.join(", ");
return {
name: service.metadata?.name ?? "",
namespace: service.metadata?.namespace,
type: service.spec?.type,
clusterIP: service.spec?.clusterIP ?? null,
externalIP:
service.status?.loadBalancer?.ingress?.[0]?.ip ||
service.spec?.externalIPs?.join(", ") ||
"<none>",
ports: portSummary,
age: getAge(service.metadata?.creationTimestamp),
labels: service.metadata?.labels,
};
});
}
async listConfigMaps(): Promise<K8sConfigMapSummary[]> {
const response = await this.coreApi.listNamespacedConfigMap({ namespace: this.namespace });
return response.items.map((configMap) => ({
name: configMap.metadata?.name ?? "",
namespace: configMap.metadata?.namespace,
data: Object.keys(configMap.data ?? {}).length,
age: getAge(configMap.metadata?.creationTimestamp),
labels: configMap.metadata?.labels,
}));
}
async listIngresses(): Promise<K8sIngressSummary[]> {
const response = await this.networkingApi.listNamespacedIngress({ namespace: this.namespace });
return response.items.map((ingress) => {
const hosts =
ingress.spec?.rules
?.map((rule) => rule.host)
.filter((host): host is string => Boolean(host))
.join(", ") || "<none>";
const addresses =
ingress.status?.loadBalancer?.ingress
?.map((item) => item.ip || item.hostname)
.filter((entry): entry is string => Boolean(entry))
.join(", ") || "<pending>";
return {
name: ingress.metadata?.name ?? "",
namespace: ingress.metadata?.namespace,
className: ingress.spec?.ingressClassName ?? null,
hosts,
address: addresses,
age: getAge(ingress.metadata?.creationTimestamp),
labels: ingress.metadata?.labels,
};
});
}
async getServiceInfo(serviceName: string): Promise<K8sServiceInfo> {
const response = await this.coreApi.readNamespacedService({
name: serviceName,
namespace: this.namespace,
});
const service = response;
const ports: K8sServicePort[] =
service.spec?.ports?.map((port) => ({
name: port.name ?? null,
protocol: port.protocol ?? null,
port: port.port,
targetPort: port.targetPort,
nodePort: port.nodePort ?? null,
})) || [];
return {
name: service.metadata?.name,
namespace: service.metadata?.namespace,
type: service.spec?.type,
clusterIP: service.spec?.clusterIP ?? null,
externalIPs: service.spec?.externalIPs || [],
loadBalancerIP: service.status?.loadBalancer?.ingress?.[0]?.ip || null,
loadBalancerHostname: service.status?.loadBalancer?.ingress?.[0]?.hostname || null,
ports,
selector: service.spec?.selector,
};
}
async listNodes(): Promise<K8sNodeSummary[]> {
const response = await this.coreApi.listNode();
return response.items.map((node) => {
const labels = node.metadata?.labels ?? {};
const roles = Object.keys(labels)
.filter((label) => label.startsWith("node-role.kubernetes.io/"))
.map((label) => label.replace("node-role.kubernetes.io/", ""))
.join(",");
const addresses = node.status?.addresses ?? [];
const internalIP = addresses.find((address) => address.type === "InternalIP")?.address;
const externalIP = addresses.find((address) => address.type === "ExternalIP")?.address;
const hostname = addresses.find((address) => address.type === "Hostname")?.address;
const readyCondition = node.status?.conditions?.find(
(condition) => condition.type === "Ready"
);
return {
name: node.metadata?.name,
status: readyCondition?.status === "True" ? "Ready" : "NotReady",
roles: roles || "<none>",
age: getAge(node.metadata?.creationTimestamp),
version: node.status?.nodeInfo?.kubeletVersion,
internalIP,
externalIP,
hostname,
};
});
}
}
function mapPodInfo(pod: k8s.V1Pod): PodInfo {
const containerStatuses = pod.status?.containerStatuses ?? [];
const readyCount = containerStatuses.filter((status) => status.ready).length;
const totalCount = containerStatuses.length;
const restarts = containerStatuses.reduce(
(accumulator, status) => accumulator + (status.restartCount ?? 0),
0
);
return {
name: pod.metadata?.name ?? "",
namespace: pod.metadata?.namespace,
status: pod.status?.phase ?? "Unknown",
ready: `${readyCount}/${totalCount}`,
restarts,
age: getAge(pod.metadata?.creationTimestamp),
labels: pod.metadata?.labels,
nodeName: pod.spec?.nodeName,
};
}