Files
aiops/backend/controller/k8s_export.go
T
attacker 77c234b060 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 ✅
2026-06-25 20:16:30 +08:00

452 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}