feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]: - 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式) - 告警引擎 isSilenced 集成 (alert_engine.go) - 前端 SilenceRuleTab + SilenceDialog - 路由 /alerts/silences 已注册 F2 审计日志导出 [done]: - 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM) - 支持 module/action/username/startTime/endTime 筛选 - 导出限制 10000 条防溢出,自动记录审计日志 - JSON 格式向后兼容 (format=json) - 前端 exportLogs 传递查询参数 同时包含累积的前期工作: - 目录重构 internal/ -> controller/model/router (v0.55-v0.57) - 后端分层架构 dto/repository/service/utils - 审计模块全功能 (终端审计/操作审计/登录日志) - asciicast v2 录制格式 - SFTP 文件浏览器 - StatStrip 紧凑统计条 - 在线会话自动刷新 - LiveMonitor WebSocket 诊断修复 验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
This commit is contained in:
1 parent
e57718998a
commit
77c234b060
314 files changed
+10050
-4065
No files matched your search
@@ -0,0 +1,451 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// yamlEntry 表示一个待打包的 YAML 资源
|
||||
type yamlEntry struct {
|
||||
namespace string // 所属命名空间(用于 tar 内路径)
|
||||
folder string // tar 内子目录
|
||||
name string // 文件名(不含 .yaml)
|
||||
yaml []byte // 清理后的 YAML
|
||||
}
|
||||
|
||||
// ExportResourcesAllNS 导出集群所有命名空间的 Deployment+Service+Ingress+ConfigMap+Secret 为 tar.gz
|
||||
func (h *KubernetesHandler) ExportResourcesAllNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 {
|
||||
c.JSON(500, gin.H{"code": 500, "message": "cluster not found"})
|
||||
return
|
||||
}
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有命名空间
|
||||
namespaces, err := client.ListNamespaces()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "message": "list namespaces failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 资源类型 → API 路径模板 + 子目录名
|
||||
type resourceDef struct {
|
||||
apiPathTmpl string // %s = namespace
|
||||
folder string
|
||||
kind string
|
||||
apiVersion string
|
||||
}
|
||||
resourceDefs := []resourceDef{
|
||||
{"/apis/apps/v1/namespaces/%s/deployments", "deployments", "Deployment", "apps/v1"},
|
||||
{"/api/v1/namespaces/%s/services", "services", "Service", "v1"},
|
||||
{"/apis/networking.k8s.io/v1/namespaces/%s/ingresses", "ingresses", "Ingress", "networking.k8s.io/v1"},
|
||||
{"/api/v1/namespaces/%s/configmaps", "configmaps", "ConfigMap", "v1"},
|
||||
{"/api/v1/namespaces/%s/secrets", "secrets", "Secret", "v1"},
|
||||
}
|
||||
|
||||
var entries []yamlEntry
|
||||
totalCount := 0
|
||||
|
||||
for _, ns := range namespaces {
|
||||
for _, rd := range resourceDefs {
|
||||
listPath := fmt.Sprintf(rd.apiPathTmpl, ns)
|
||||
yamlBytes, err := client.GetResourceYAML(listPath)
|
||||
if err != nil {
|
||||
log.Printf("[ExportAllNS] %s/%s list failed: %v", ns, rd.folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var listObj map[string]interface{}
|
||||
if err := yaml.Unmarshal(yamlBytes, &listObj); err != nil {
|
||||
log.Printf("[ExportAllNS] %s/%s parse failed: %v", ns, rd.folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
items, ok := listObj["items"].([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
meta, _ := itemMap["metadata"].(map[string]interface{})
|
||||
name, _ := meta["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
itemMap["apiVersion"] = rd.apiVersion
|
||||
itemMap["kind"] = rd.kind
|
||||
cleanResourceYAML(itemMap)
|
||||
|
||||
cleanedBytes, err := yaml.Marshal(itemMap)
|
||||
if err != nil {
|
||||
log.Printf("[ExportAllNS] marshal %s/%s/%s failed: %v", ns, rd.folder, name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, yamlEntry{
|
||||
namespace: ns,
|
||||
folder: rd.folder,
|
||||
name: name,
|
||||
yaml: cleanedBytes,
|
||||
})
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalCount == 0 {
|
||||
c.JSON(404, gin.H{"code": 404, "message": "集群没有可导出的资源"})
|
||||
return
|
||||
}
|
||||
|
||||
tarGz, err := buildTarGzAllNS(entries)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "message": "打包失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
clusterName := c.Param("clusterCode")
|
||||
filename := fmt.Sprintf("cluster-%s-%s.tar.gz", clusterName, time.Now().Format("20060102-150405"))
|
||||
c.Header("Content-Type", "application/gzip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(tarGz)))
|
||||
c.Data(http.StatusOK, "application/gzip", tarGz)
|
||||
|
||||
log.Printf("[ExportAllNS] exported %d resources across %d namespaces (cluster %d)", totalCount, len(namespaces), clusterID)
|
||||
}
|
||||
|
||||
// ExportResourcesNS 导出指定命名空间的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz
|
||||
func (h *KubernetesHandler) ExportResourcesNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 {
|
||||
c.JSON(500, gin.H{"code": 500, "message": "cluster not found"})
|
||||
return
|
||||
}
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
ns := c.Param("namespace")
|
||||
if ns == "" {
|
||||
c.JSON(400, gin.H{"code": 400, "message": "namespace is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// 资源类型 → API 路径模板 + 子目录名
|
||||
type resourceDef struct {
|
||||
apiPathTmpl string // %s = namespace
|
||||
folder string // tar 内子目录
|
||||
kind string
|
||||
apiVersion string
|
||||
}
|
||||
resourceDefs := []resourceDef{
|
||||
{"/apis/apps/v1/namespaces/%s/deployments", "deployments", "Deployment", "apps/v1"},
|
||||
{"/api/v1/namespaces/%s/services", "services", "Service", "v1"},
|
||||
{"/apis/networking.k8s.io/v1/namespaces/%s/ingresses", "ingresses", "Ingress", "networking.k8s.io/v1"},
|
||||
{"/api/v1/namespaces/%s/configmaps", "configmaps", "ConfigMap", "v1"},
|
||||
{"/api/v1/namespaces/%s/secrets", "secrets", "Secret", "v1"},
|
||||
}
|
||||
|
||||
// 收集所有资源的 YAML
|
||||
var entries []yamlEntry
|
||||
totalCount := 0
|
||||
|
||||
for _, rd := range resourceDefs {
|
||||
listPath := fmt.Sprintf(rd.apiPathTmpl, ns)
|
||||
yamlBytes, err := client.GetResourceYAML(listPath)
|
||||
if err != nil {
|
||||
log.Printf("[ExportResources] list %s failed: %v", rd.folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 YAML list(K8s List 返回 kind: XxxList)
|
||||
var listObj map[string]interface{}
|
||||
if err := yaml.Unmarshal(yamlBytes, &listObj); err != nil {
|
||||
log.Printf("[ExportResources] parse %s list failed: %v", rd.folder, err)
|
||||
continue
|
||||
}
|
||||
|
||||
items, ok := listObj["items"].([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取资源名
|
||||
meta, _ := itemMap["metadata"].(map[string]interface{})
|
||||
name, _ := meta["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 设置 apiVersion 和 kind
|
||||
itemMap["apiVersion"] = rd.apiVersion
|
||||
itemMap["kind"] = rd.kind
|
||||
|
||||
// 清理运行时字段
|
||||
cleanResourceYAML(itemMap)
|
||||
|
||||
// 序列化为 YAML
|
||||
cleanedBytes, err := yaml.Marshal(itemMap)
|
||||
if err != nil {
|
||||
log.Printf("[ExportResources] marshal %s/%s failed: %v", rd.folder, name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, yamlEntry{
|
||||
folder: rd.folder,
|
||||
name: name,
|
||||
yaml: cleanedBytes,
|
||||
})
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalCount == 0 {
|
||||
c.JSON(404, gin.H{"code": 404, "message": "命名空间 " + ns + " 没有可导出的资源"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 tar.gz
|
||||
tarGz, err := buildTarGz(entries, ns)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"code": 500, "message": "打包失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s-%s.tar.gz", ns, time.Now().Format("20060102-150405"))
|
||||
c.Header("Content-Type", "application/gzip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(tarGz)))
|
||||
c.Data(http.StatusOK, "application/gzip", tarGz)
|
||||
|
||||
log.Printf("[ExportResources] exported %d resources from namespace %s (cluster %d)", totalCount, ns, clusterID)
|
||||
}
|
||||
|
||||
// cleanResourceYAML 清理 K8s 资源中的运行时字段(通用,适用于所有资源类型)
|
||||
func cleanResourceYAML(obj map[string]interface{}) {
|
||||
// 清理 metadata
|
||||
if meta, ok := obj["metadata"].(map[string]interface{}); ok {
|
||||
delete(meta, "managedFields")
|
||||
delete(meta, "creationTimestamp")
|
||||
delete(meta, "resourceVersion")
|
||||
delete(meta, "uid")
|
||||
delete(meta, "selfLink")
|
||||
delete(meta, "generation")
|
||||
delete(meta, "ownerReferences")
|
||||
delete(meta, "finalizers")
|
||||
delete(meta, "generateName")
|
||||
|
||||
// 清理运行时 annotations
|
||||
if annotations, ok := meta["annotations"].(map[string]interface{}); ok {
|
||||
cleanRuntimeAnnotations(annotations)
|
||||
if len(annotations) == 0 {
|
||||
delete(meta, "annotations")
|
||||
}
|
||||
}
|
||||
// 移除空的 labels
|
||||
if labels, ok := meta["labels"].(map[string]interface{}); ok && len(labels) == 0 {
|
||||
delete(meta, "labels")
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 status
|
||||
delete(obj, "status")
|
||||
|
||||
// 清理 spec 中的运行时字段
|
||||
if spec, ok := obj["spec"].(map[string]interface{}); ok {
|
||||
kind, _ := obj["kind"].(string)
|
||||
|
||||
// Service: 移除动态分配字段和默认值
|
||||
if kind == "Service" {
|
||||
delete(spec, "clusterIP")
|
||||
delete(spec, "clusterIPs")
|
||||
delete(spec, "internalTrafficPolicy")
|
||||
delete(spec, "ipFamilies")
|
||||
delete(spec, "ipFamilyPolicy")
|
||||
// sessionAffinity 默认 "None" → 移除
|
||||
if sa, ok := spec["sessionAffinity"].(string); ok && sa == "None" {
|
||||
delete(spec, "sessionAffinity")
|
||||
}
|
||||
delete(spec, "allocateLoadBalancerNodePorts")
|
||||
// 移除 ports 内的 nodePort(仅 NodePort/LoadBalancer 类型)
|
||||
if ports, ok := spec["ports"].([]interface{}); ok {
|
||||
for _, p := range ports {
|
||||
if pm, ok := p.(map[string]interface{}); ok {
|
||||
delete(pm, "nodePort")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deployment / StatefulSet / DaemonSet: 清理运行时和默认值
|
||||
if kind == "Deployment" || kind == "StatefulSet" || kind == "DaemonSet" {
|
||||
// 清理 template
|
||||
if template, ok := spec["template"].(map[string]interface{}); ok {
|
||||
if tMeta, ok := template["metadata"].(map[string]interface{}); ok {
|
||||
delete(tMeta, "creationTimestamp")
|
||||
if tAnn, ok := tMeta["annotations"].(map[string]interface{}); ok {
|
||||
cleanRuntimeAnnotations(tAnn)
|
||||
if len(tAnn) == 0 {
|
||||
delete(tMeta, "annotations")
|
||||
}
|
||||
}
|
||||
}
|
||||
if tSpec, ok := template["spec"].(map[string]interface{}); ok {
|
||||
delete(tSpec, "nodeName")
|
||||
delete(tSpec, "serviceAccountName") // 移除 default 值
|
||||
// 清理容器内的 terminationMessagePath/Policy 默认值
|
||||
if containers, ok := tSpec["containers"].([]interface{}); ok {
|
||||
for _, c := range containers {
|
||||
if cm, ok := c.(map[string]interface{}); ok {
|
||||
if tmp, ok := cm["terminationMessagePath"].(string); ok && tmp == "/dev/termination-log" {
|
||||
delete(cm, "terminationMessagePath")
|
||||
}
|
||||
if tmp, ok := cm["terminationMessagePolicy"].(string); ok && tmp == "File" {
|
||||
delete(cm, "terminationMessagePolicy")
|
||||
}
|
||||
// 清理默认 imagePullPolicy
|
||||
if ip, ok := cm["imagePullPolicy"].(string); ok && ip == "Always" {
|
||||
delete(cm, "imagePullPolicy")
|
||||
}
|
||||
// 清理空 resources
|
||||
if res, ok := cm["resources"].(map[string]interface{}); ok && len(res) == 0 {
|
||||
delete(cm, "resources")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 清理 dnsPolicy 默认值
|
||||
if dp, ok := tSpec["dnsPolicy"].(string); ok && dp == "ClusterFirst" {
|
||||
delete(tSpec, "dnsPolicy")
|
||||
}
|
||||
if rp, ok := tSpec["restartPolicy"].(string); ok && rp == "Always" {
|
||||
delete(tSpec, "restartPolicy")
|
||||
}
|
||||
if tp, ok := tSpec["terminationGracePeriodSeconds"].(float64); ok && tp == 30 {
|
||||
delete(tSpec, "terminationGracePeriodSeconds")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 清理默认值
|
||||
if rhl, ok := spec["revisionHistoryLimit"].(float64); ok && rhl == 10 {
|
||||
delete(spec, "revisionHistoryLimit")
|
||||
}
|
||||
if pds, ok := spec["progressDeadlineSeconds"].(float64); ok && pds == 600 {
|
||||
delete(spec, "progressDeadlineSeconds")
|
||||
}
|
||||
}
|
||||
|
||||
// Ingress: 清理默认值
|
||||
if kind == "Ingress" {
|
||||
if ic, ok := spec["ingressClassName"].(string); ok && ic == "" {
|
||||
delete(spec, "ingressClassName")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanRuntimeAnnotations 移除 K8s 系统/运行时 annotations
|
||||
func cleanRuntimeAnnotations(annotations map[string]interface{}) {
|
||||
runtimeKeys := []string{
|
||||
"kubectl.kubernetes.io/last-applied-configuration",
|
||||
"deployment.kubernetes.io/revision",
|
||||
"control-plane.alpha.kubernetes.io/leader",
|
||||
"autoscaling.alpha.kubernetes.io/conditions",
|
||||
"pv.kubernetes.io/bind-completed",
|
||||
"pv.kubernetes.io/bound-by-controller",
|
||||
}
|
||||
for _, k := range runtimeKeys {
|
||||
delete(annotations, k)
|
||||
}
|
||||
}
|
||||
|
||||
// buildTarGz 构建 tar.gz 包(单命名空间,路径: ns/folder/name.yaml)
|
||||
func buildTarGz(entries []yamlEntry, namespace string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
for _, e := range entries {
|
||||
ns := e.namespace
|
||||
if ns == "" {
|
||||
ns = namespace
|
||||
}
|
||||
path := fmt.Sprintf("%s/%s/%s.yaml", ns, e.folder, e.name)
|
||||
header := &tar.Header{
|
||||
Name: path,
|
||||
Mode: 0644,
|
||||
Size: int64(len(e.yaml)),
|
||||
}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return nil, fmt.Errorf("tar write header %s: %w", path, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, bytes.NewReader(e.yaml)); err != nil {
|
||||
return nil, fmt.Errorf("tar write body %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("tar close: %w", err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("gzip close: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildTarGzAllNS 构建 tar.gz 包(多命名空间,路径: ns/folder/name.yaml)
|
||||
func buildTarGzAllNS(entries []yamlEntry) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
for _, e := range entries {
|
||||
path := fmt.Sprintf("%s/%s/%s.yaml", e.namespace, e.folder, e.name)
|
||||
header := &tar.Header{
|
||||
Name: path,
|
||||
Mode: 0644,
|
||||
Size: int64(len(e.yaml)),
|
||||
}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return nil, fmt.Errorf("tar write header %s: %w", path, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, bytes.NewReader(e.yaml)); err != nil {
|
||||
return nil, fmt.Errorf("tar write body %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("tar close: %w", err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("gzip close: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
Reference in new issue
Block a user