主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
// k8s-yaml-utils.ts — K8s YAML 编辑前深度清理工具
|
||
// 参考 opsx-agent clean_kubernetes_yaml() 设计,移除所有运行时/默认值/系统字段
|
||
// 作者: Logan <admin@attacker.club>
|
||
|
||
/**
|
||
* 编辑前深度清理 YAML 文本(Kuboard 级精简):
|
||
* - 移除 status 整块
|
||
* - 移除 metadata 噪音字段(uid/resourceVersion/managedFields/creationTimestamp 等)
|
||
* - 移除 annotations 中的 kubectl 系统注解
|
||
* - 移除 labels 中的 pod-template-hash
|
||
* - 移除 spec 中 K8s 默认值(dnsPolicy/restartPolicy/schedulerName 等)
|
||
* - 移除容器默认值(imagePullPolicy/terminationMessagePath 等)
|
||
* - 移除系统卷(kube-api-access-*)及对应 volumeMount
|
||
* - 移除 Deployment 默认字段(progressDeadlineSeconds/revisionHistoryLimit)
|
||
*/
|
||
export function cleanYAML(yaml: string): string {
|
||
if (!yaml) return '';
|
||
const lines = yaml.replace(/\r\n/g, '\n').split('\n');
|
||
const out: string[] = [];
|
||
let i = 0;
|
||
|
||
// ===== 移除配置 =====
|
||
const METADATA_LINE_KEYS = new Set([
|
||
'uid', 'resourceVersion', 'creationTimestamp', 'generation',
|
||
'selfLink', 'clusterName', 'generateName', 'deletionTimestamp',
|
||
// managedFields 走 METADATA_BLOCK_KEYS 整块删除(含同级数组项)// Logan.Li
|
||
]);
|
||
const METADATA_BLOCK_KEYS = new Set(['managedFields', 'ownerReferences', 'finalizers']);
|
||
|
||
// K8s 默认值 → 值匹配时移除
|
||
const SPEC_DEFAULTS: Record<string, string[]> = {
|
||
dnsPolicy: ['ClusterFirst'],
|
||
restartPolicy: ['Always'],
|
||
schedulerName: ['default-scheduler'],
|
||
terminationGracePeriodSeconds: ['30'],
|
||
serviceAccountName: ['default'],
|
||
enableServiceLinks: ['true'],
|
||
automountServiceAccountToken: ['true'],
|
||
progressDeadlineSeconds: ['600'],
|
||
revisionHistoryLimit: ['10'],
|
||
sessionAffinity: ['None'],
|
||
externalTrafficPolicy: ['Cluster'],
|
||
internalTrafficPolicy: ['Cluster'],
|
||
ipFamilyPolicy: ['SingleStack'],
|
||
allocateLoadBalancerNodePorts: ['true'],
|
||
preemptionPolicy: ['PreemptLowerPriority'],
|
||
priority: ['0'],
|
||
};
|
||
const CONTAINER_DEFAULTS: Record<string, string[]> = {
|
||
imagePullPolicy: ['Always', 'IfNotPresent'],
|
||
terminationMessagePath: ['/dev/termination-log'],
|
||
terminationMessagePolicy: ['File'],
|
||
stdin: ['false'],
|
||
tty: ['false'],
|
||
stdinOnce: ['false'],
|
||
};
|
||
const SERVICE_RUNTIME_KEYS = new Set([
|
||
'clusterIP', 'clusterIPs', 'ipFamilies', 'healthCheckNodePort',
|
||
]);
|
||
const ALWAYS_REMOVE_ANNOTATIONS = new Set([
|
||
'kubectl.kubernetes.io/last-applied-configuration',
|
||
'kubectl.kubernetes.io/change-cause',
|
||
'deployment.kubernetes.io/revision',
|
||
'deployment.kubernetes.io/desired-replicas',
|
||
'deployment.kubernetes.io/max-replicas',
|
||
'kubectl.kubernetes.io/restartedAt',
|
||
'control-plane.alpha.kubernetes.io/leader',
|
||
'autoscaling.alpha.kubernetes.io/conditions',
|
||
'kubernetes.io/config.hash',
|
||
'pv.kubernetes.io/bind-completed',
|
||
'pv.kubernetes.io/bound-by-controller',
|
||
]);
|
||
const STRIP_LABEL_KEYS = new Set(['pod-template-hash', 'controller-revision-hash']);
|
||
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
|
||
const SYSTEM_MOUNT_PATHS = ['/var/run/secrets/kubernetes.io/serviceaccount'];
|
||
|
||
// 当前作用域追踪
|
||
let inMetadata = false;
|
||
let inAnnotations = false;
|
||
let inSpec = false;
|
||
let inTemplateSpec = false;
|
||
let inContainers = false;
|
||
let inInitContainers = false;
|
||
let inVolumeMounts = false;
|
||
let inVolumes = false;
|
||
let _metadataIndent = 0;
|
||
let _specIndent = 0;
|
||
let templateSpecIndent = 0;
|
||
|
||
function getIndent(line: string): number {
|
||
return line.length - line.trimStart().length;
|
||
}
|
||
|
||
function extractKey(line: string): string | null {
|
||
const m = line.trimStart().match(/^([a-zA-Z_][a-zA-Z0-9_./-]*)\s*:/);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
function hasInlineValue(line: string): boolean {
|
||
const afterColon = line.trim().split(':').slice(1).join(':').trim();
|
||
return afterColon !== '' && !/^[>|]/.test(afterColon);
|
||
}
|
||
|
||
// 跳过一个 YAML 块(包括当前行的所有子行)
|
||
function skipBlock(startIndent: number): void {
|
||
i++;
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
const t = l.trimStart();
|
||
if (t === '' || t.startsWith('#')) { i++; continue; }
|
||
if (getIndent(l) <= startIndent) break;
|
||
i++;
|
||
}
|
||
}
|
||
|
||
// 检查当前行是否是某个 volume 条目的开始(系统卷)
|
||
function isSystemVolume(line: string): boolean {
|
||
const key = extractKey(line);
|
||
if (!key) return false;
|
||
return SYSTEM_VOLUME_PREFIXES.some(prefix => key.startsWith(prefix));
|
||
}
|
||
|
||
// 检查 volumeMount 是否指向系统挂载路径
|
||
function _isSystemMountLine(line: string): boolean {
|
||
return SYSTEM_MOUNT_PATHS.some(p => line.includes(`mountPath: ${p}`));
|
||
}
|
||
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
const trimmed = line.trimStart();
|
||
const indent = getIndent(line);
|
||
const key = extractKey(line);
|
||
|
||
// 空行和注释保留
|
||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// === 顶层 status: 整块移除 ===
|
||
if (indent === 0 && key === 'status') {
|
||
skipBlock(0);
|
||
continue;
|
||
}
|
||
|
||
// === 顶层 metadata: 跟踪作用域 ===
|
||
if (indent === 0 && key === 'metadata') {
|
||
inMetadata = true;
|
||
_metadataIndent = 0;
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
if (indent === 0 && key !== 'metadata') {
|
||
inMetadata = false;
|
||
inAnnotations = false;
|
||
}
|
||
|
||
// === 顶层 spec: 跟踪作用域 ===
|
||
if (indent === 0 && key === 'spec') {
|
||
inSpec = true;
|
||
_specIndent = 0;
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
if (indent === 0 && key !== 'spec') {
|
||
inSpec = false;
|
||
inTemplateSpec = false;
|
||
inContainers = false;
|
||
inInitContainers = false;
|
||
inVolumes = false;
|
||
}
|
||
|
||
// === metadata 子字段 ===
|
||
if (inMetadata) {
|
||
const metaIndent = 2; // metadata 子字段通常缩进 2
|
||
|
||
// 标注块开始/结束
|
||
if (indent === metaIndent && key === 'annotations') {
|
||
inAnnotations = true;
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
if (indent <= metaIndent && key !== 'annotations') {
|
||
inAnnotations = false;
|
||
}
|
||
|
||
// labels 下控制器自动标签
|
||
if (indent >= metaIndent && indent <= metaIndent + 2) {
|
||
const labelKey = extractKey(line);
|
||
if (labelKey && STRIP_LABEL_KEYS.has(labelKey)) {
|
||
i++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// metadata 单行字段移除
|
||
if (indent === metaIndent && key && METADATA_LINE_KEYS.has(key)) {
|
||
if (hasInlineValue(line)) {
|
||
i++;
|
||
} else {
|
||
skipBlock(metaIndent);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// metadata 块字段移除(managedFields/ownerReferences)
|
||
// K8s 数组项与 key 同级缩进(如 managedFields:\n - apiVersion),需一并跳过 // Logan.Li
|
||
if (indent === metaIndent && key && METADATA_BLOCK_KEYS.has(key)) {
|
||
i++;
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
const t = l.trimStart();
|
||
if (t === '' || t.startsWith('#')) { i++; continue; }
|
||
const ind = getIndent(l);
|
||
if (ind > metaIndent || (ind === metaIndent && t.startsWith('-'))) { i++; continue; }
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// annotations 内的系统注解
|
||
if (inAnnotations && indent > metaIndent) {
|
||
if (key && ALWAYS_REMOVE_ANNOTATIONS.has(key)) {
|
||
if (hasInlineValue(line)) {
|
||
i++;
|
||
} else {
|
||
// 多行 block scalar
|
||
const afterColon = trimmed.split(':').slice(1).join(':').trim();
|
||
if (/^[>|]/.test(afterColon)) {
|
||
skipBlock(indent);
|
||
} else {
|
||
i++;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// === spec 子字段 ===
|
||
if (inSpec) {
|
||
// spec.template.spec 跟踪
|
||
if (indent === 2 && key === 'template') {
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
// spec.selector — 不动(关键字段)
|
||
// spec.strategy — 不动
|
||
|
||
// spec 下的 K8s 默认值 / Service 运行时字段
|
||
if (indent === 2 && key) {
|
||
if (SERVICE_RUNTIME_KEYS.has(key)) {
|
||
if (hasInlineValue(line)) i++; else skipBlock(indent);
|
||
continue;
|
||
}
|
||
if (SPEC_DEFAULTS[key]) {
|
||
const afterColon = trimmed.split(':').slice(1).join(':').trim();
|
||
if (SPEC_DEFAULTS[key].includes(afterColon)) {
|
||
i++;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// spec 下的 nodeName(运行时分配)
|
||
if (indent === 2 && key === 'nodeName') {
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// Deployment 默认滚动策略整块移除 // Logan.Li
|
||
if (indent === 2 && key === 'strategy') {
|
||
const blockLines: string[] = [];
|
||
let j = i;
|
||
while (j < lines.length) {
|
||
const l = lines[j];
|
||
const t = l.trimStart();
|
||
if (t === '' || t.startsWith('#')) { blockLines.push(l); j++; continue; }
|
||
if (j > i && getIndent(l) <= indent) break;
|
||
blockLines.push(l);
|
||
j++;
|
||
}
|
||
const blockText = blockLines.join('\n');
|
||
const isDefaultRolling = blockText.includes('type: RollingUpdate')
|
||
&& (blockText.includes('maxUnavailable: 25%') || blockText.includes('maxUnavailable: 0'))
|
||
&& (blockText.includes('maxSurge: 25%') || blockText.includes('maxSurge: 1'));
|
||
if (isDefaultRolling) {
|
||
i = j;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// template.spec 内部
|
||
if (indent === 2 && key === 'spec') {
|
||
// 这是 template.spec 的开始
|
||
inTemplateSpec = true;
|
||
templateSpecIndent = indent;
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// === template.spec 内部 ===
|
||
if (inTemplateSpec && indent > templateSpecIndent) {
|
||
// containers 数组开始
|
||
if (key === 'containers') {
|
||
inContainers = true;
|
||
inInitContainers = false;
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
if (key === 'initContainers') {
|
||
inInitContainers = true;
|
||
inContainers = false;
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// volumes 数组开始
|
||
if (key === 'volumes') {
|
||
inVolumes = true;
|
||
out.push(line);
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// template.spec 默认值
|
||
if (key && SPEC_DEFAULTS[key]) {
|
||
const afterColon = trimmed.split(':').slice(1).join(':').trim();
|
||
if (SPEC_DEFAULTS[key].includes(afterColon)) {
|
||
i++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// nodeName
|
||
if (key === 'nodeName') {
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// serviceAccount (非 serviceAccountName 时的冗余字段)
|
||
if (key === 'serviceAccount' && trimmed.endsWith(':')) {
|
||
if (hasInlineValue(line)) {
|
||
i++;
|
||
} else {
|
||
skipBlock(indent);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// === containers/initContainers 内部 ===
|
||
if ((inContainers || inInitContainers) && indent >= 6) {
|
||
// 容器字段的默认值
|
||
if (key && CONTAINER_DEFAULTS[key]) {
|
||
const afterColon = trimmed.split(':').slice(1).join(':').trim();
|
||
if (CONTAINER_DEFAULTS[key].includes(afterColon)) {
|
||
i++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// volumeMounts 区域
|
||
if (key === 'volumeMounts') {
|
||
inVolumeMounts = true;
|
||
// 不直接输出,等下面判断每个 mount
|
||
}
|
||
|
||
// 系统 volumeMount 条目
|
||
if (inVolumeMounts && trimmed.startsWith('- name:')) {
|
||
// 检查这个 volume mount 块内是否有系统路径
|
||
const blockLines: string[] = [line];
|
||
let j = i + 1;
|
||
let isSystem = false;
|
||
while (j < lines.length) {
|
||
const nl = lines[j].trimStart();
|
||
if (nl === '' || nl.startsWith('#')) { blockLines.push(lines[j]); j++; continue; }
|
||
if (getIndent(lines[j]) <= indent) break;
|
||
if (isSystemMountPath(nl)) isSystem = true;
|
||
blockLines.push(lines[j]);
|
||
j++;
|
||
}
|
||
if (isSystem) {
|
||
i = j; // 跳过整个 mount 块
|
||
continue;
|
||
}
|
||
// 非系统 mount,保留
|
||
inVolumeMounts = false;
|
||
}
|
||
}
|
||
|
||
// === volumes 区域 ===
|
||
if (inVolumes && indent >= 4) {
|
||
// 系统卷条目(- name: kube-api-access-xxx)
|
||
if (trimmed.startsWith('- name:') && isSystemVolume(trimmed)) {
|
||
// 跳过整个 volume 块
|
||
const blockIndent = indent;
|
||
i++;
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
const t = l.trimStart();
|
||
if (t === '' || t.startsWith('#')) { i++; continue; }
|
||
if (getIndent(l) <= blockIndent) break;
|
||
i++;
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 连续空行压缩
|
||
if (trimmed === '' && out.length > 0 && out[out.length - 1].trim() === '') {
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
out.push(line);
|
||
i++;
|
||
}
|
||
|
||
// 末尾清理
|
||
const result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
||
return result + '\n';
|
||
}
|
||
|
||
function isSystemMountPath(line: string): boolean {
|
||
return line.includes('mountPath:') && (
|
||
line.includes('/var/run/secrets/kubernetes.io') ||
|
||
line.includes('/var/run/secrets/sa')
|
||
);
|
||
}
|
||
|
||
/** 解析简单 "key: value" 行 */
|
||
export function parseScalar(line: string): { key: string; value: string } | null {
|
||
const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
||
if (!m) return null;
|
||
return { key: m[1], value: m[2].trim().replace(/^["']|["']$/g, '') };
|
||
}
|
||
|
||
/** 状态色 */
|
||
export const STATUS_COLORS = {
|
||
green: '#10b981',
|
||
orange: '#f59e0b',
|
||
red: '#ef4444',
|
||
blue: '#326CE5',
|
||
} as const;
|
||
|
||
export function statusColor(status: string | undefined): string {
|
||
if (!status) return '#64748b';
|
||
const s = status.toLowerCase();
|
||
if (['running', 'available', 'ready', 'active', 'completed', 'succeeded'].includes(s)) return STATUS_COLORS.green;
|
||
if (['pending', 'updating', 'progressing', 'containercreating'].includes(s)) return STATUS_COLORS.orange;
|
||
if (['failed', 'error', 'crashloopbackoff', 'imagepullbackoff', 'errimageneverpull'].includes(s)) return STATUS_COLORS.red;
|
||
return STATUS_COLORS.blue;
|
||
}
|
||
|
||
/** 按镜像聚合 */
|
||
export function aggregateByImage<T extends { image?: string; images?: string[] }>(items: T[]): Array<T & { _count: number }> {
|
||
const map = new Map<string, T & { _count: number }>();
|
||
for (const it of items) {
|
||
const img = it.image || (Array.isArray(it.images) ? it.images[0] : '') || 'unknown';
|
||
const existing = map.get(img);
|
||
if (existing) {
|
||
existing._count += 1;
|
||
} else {
|
||
map.set(img, { ...it, _count: 1 });
|
||
}
|
||
}
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
/** 格式化 K8s 时间戳 */
|
||
export function formatK8sTime(ts: string | undefined | null): string {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts);
|
||
if (isNaN(d.getTime())) return ts;
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||
}
|
||
|
||
export function elapsedSeconds(start?: string | null, end?: string | null): number | null {
|
||
if (!start || !end) return null;
|
||
const s = new Date(start).getTime();
|
||
const e = new Date(end).getTime();
|
||
if (isNaN(s) || isNaN(e) || e < s) return null;
|
||
return Math.max(0, Math.round((e - s) / 1000));
|
||
}
|
||
|
||
export function formatDuration(sec: number | null | undefined): string {
|
||
if (sec == null) return '-';
|
||
if (sec < 60) return `${sec}s`;
|
||
if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
|
||
const h = Math.floor(sec / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
return `${h}h${m}m`;
|
||
}
|