From 2d73661dd1508ed717c836a9b60ed3a208921a2e Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 25 Oct 2026 21:28:32 +0800 Subject: [PATCH] feat: complete Loki query, insights, settings and deployment workflows Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application. Validated with go test ./..., npm run build, and npm run lint. --- .gitignore | 2 +- AGENTS.md | 14 +- CLAUDE.md | 8 +- Dockerfile | 54 - backend/controllers/alerts.go | 283 +++++ backend/controllers/cluster.go | 44 + backend/controllers/deployment.go | 40 +- backend/controllers/image_registry.go | 30 + backend/controllers/logs.go | 27 +- backend/controllers/storage.go | 14 +- backend/dto/alerts.go | 27 + backend/dto/deployment.go | 10 +- backend/dto/image_registry.go | 6 + backend/go.mod | 39 +- backend/go.sum | 130 ++- backend/main.go | 6 +- backend/models/alert.go | 42 + backend/models/deployment.go | 4 + backend/models/log_source.go | 3 +- backend/models/notify_channel.go | 23 + backend/models/storage.go | 23 +- backend/repository/db.go | 6 + backend/routers/router.go | 29 + backend/seed/alert_rules.go | 83 ++ backend/seed/seed.go | 3 + backend/services/alert.go | 265 +++++ backend/services/cluster.go | 8 + backend/services/cluster_info.go | 142 +-- backend/services/cluster_workloads.go | 63 + backend/services/cluster_workloads_test.go | 38 + backend/services/collector.go | 3 + backend/services/collector_deployer.go | 268 ++--- backend/services/collector_sidecar_mount.go | 150 +++ .../services/collector_sidecar_mount_test.go | 64 + backend/services/collector_templates.go | 93 +- backend/services/deployment.go | 125 +- backend/services/deployment_config.go | 120 ++ backend/services/deployment_config_test.go | 138 +++ backend/services/deployment_retention.go | 79 ++ backend/services/deployment_retention_test.go | 68 ++ backend/services/grafana_deployer.go | 438 ------- backend/services/host.go | 27 + backend/services/image_registry.go | 131 +++ backend/services/ingress_access.go | 184 +++ backend/services/ingress_access_test.go | 37 + backend/services/k8s_client.go | 35 + backend/services/k8s_resources.go | 331 ++++++ backend/services/ldap.go | 22 +- backend/services/log_policy.go | 4 +- backend/services/log_query.go | 423 ++----- backend/services/log_query_test.go | 14 + backend/services/log_source.go | 350 ++++++ backend/services/loki_config.go | 370 ++++++ backend/services/loki_deployer.go | 1030 +---------------- backend/services/loki_docker.go | 149 +++ backend/services/loki_docker_test.go | 41 + backend/services/loki_manifest.go | 243 ++++ backend/services/notifier.go | 146 +++ backend/services/notify_channel.go | 226 ++++ backend/services/storage.go | 21 + backend/services/storage_check.go | 14 +- backend/services/storage_update_test.go | 34 + frontend/.gitignore | 2 +- frontend/src/api/alerts/index.ts | 66 ++ frontend/src/api/cluster/index.ts | 10 + frontend/src/api/collector/index.ts | 3 + frontend/src/api/deployment/index.ts | 6 + frontend/src/api/index.ts | 2 + frontend/src/api/logs/index.ts | 32 + frontend/src/api/settings/index.ts | 17 + frontend/src/api/storage/index.ts | 1 + frontend/src/components/Layout.tsx | 24 +- frontend/src/components/PageContainer.tsx | 9 +- frontend/src/components/PageHeader.tsx | 12 +- frontend/src/components/ResourceCard.tsx | 97 ++ frontend/src/components/index.ts | 1 + frontend/src/index.css | 12 +- frontend/src/pages/alerts/index.tsx | 362 ++++++ .../pages/clusters/components/ClusterCard.tsx | 111 +- .../clusters/components/ClusterFormDialog.tsx | 50 +- frontend/src/pages/clusters/index.tsx | 17 +- .../collectors/components/CollectorCard.tsx | 144 +-- .../components/CollectorEditDialog.tsx | 74 ++ .../components/CollectorFormDialog.tsx | 119 +- frontend/src/pages/collectors/index.tsx | 7 +- .../deployments/components/DeploymentCard.tsx | 162 +-- .../components/DeploymentFormDialog.tsx | 76 +- .../components/RetentionDialog.tsx | 146 +++ frontend/src/pages/deployments/form-schema.ts | 22 +- frontend/src/pages/deployments/index.tsx | 11 +- .../src/pages/hosts/components/HostCard.tsx | 106 +- .../pages/hosts/components/HostFormDialog.tsx | 52 +- frontend/src/pages/hosts/index.tsx | 16 +- .../logs/components/AccessRuleEditor.tsx | 51 + .../logs/components/HighlightedLogLine.tsx | 11 + .../logs/components/IngressInsightsTable.tsx | 62 + .../logs/components/LogContextDialog.tsx | 45 + .../pages/logs/components/LogHistogram.tsx | 68 ++ .../pages/logs/components/LogResultViews.tsx | 125 ++ .../src/pages/logs/components/LogResults.tsx | 212 ++++ .../pages/logs/components/LogResultsPanel.tsx | 72 ++ .../logs/components/LogVolumeTimeline.tsx | 65 ++ .../logs/components/LokiLabelBrowser.tsx | 71 ++ .../logs/components/PolicyGroupsPage.tsx | 96 ++ .../pages/logs/components/QueryEmptyState.tsx | 11 + .../src/pages/logs/components/QueryInput.tsx | 155 +++ .../logs/components/QuickQueryTemplates.tsx | 81 ++ .../logs/components/SearchableValueSelect.tsx | 66 ++ .../pages/logs/components/TimeRangePicker.tsx | 68 ++ frontend/src/pages/logs/index.tsx | 244 ++++ frontend/src/pages/logs/ingress-access-log.ts | 100 ++ frontend/src/pages/logs/ingress-insights.ts | 77 ++ frontend/src/pages/logs/query-history.ts | 16 + frontend/src/pages/logs/query-utils.ts | 37 + .../components/ImageRegistrySection.tsx | 119 ++ .../pages/settings/components/LdapSection.tsx | 2 +- .../settings/components/NotifySection.tsx | 240 ++++ .../settings/components/ThemeSection.tsx | 41 + frontend/src/pages/settings/index.tsx | 46 +- .../pages/storage/components/StorageCard.tsx | 112 +- .../storage/components/StorageFormDialog.tsx | 47 +- frontend/src/pages/storage/index.tsx | 16 +- .../pages/wizard/components/StepDeploy.tsx | 4 +- .../src/pages/wizard/components/StepEnv.tsx | 4 +- frontend/src/pages/wizard/index.tsx | 2 +- frontend/src/router/routes.tsx | 5 +- frontend/src/types/index.ts | 9 +- 127 files changed, 8117 insertions(+), 2876 deletions(-) create mode 100644 backend/controllers/alerts.go create mode 100644 backend/controllers/image_registry.go create mode 100644 backend/dto/alerts.go create mode 100644 backend/dto/image_registry.go create mode 100644 backend/models/alert.go create mode 100644 backend/models/notify_channel.go create mode 100644 backend/seed/alert_rules.go create mode 100644 backend/services/alert.go create mode 100644 backend/services/cluster_workloads.go create mode 100644 backend/services/cluster_workloads_test.go create mode 100644 backend/services/collector_sidecar_mount.go create mode 100644 backend/services/collector_sidecar_mount_test.go create mode 100644 backend/services/deployment_config.go create mode 100644 backend/services/deployment_config_test.go create mode 100644 backend/services/deployment_retention.go create mode 100644 backend/services/deployment_retention_test.go delete mode 100644 backend/services/grafana_deployer.go create mode 100644 backend/services/image_registry.go create mode 100644 backend/services/ingress_access.go create mode 100644 backend/services/ingress_access_test.go create mode 100644 backend/services/k8s_resources.go create mode 100644 backend/services/log_source.go create mode 100644 backend/services/loki_config.go create mode 100644 backend/services/loki_docker.go create mode 100644 backend/services/loki_docker_test.go create mode 100644 backend/services/loki_manifest.go create mode 100644 backend/services/notifier.go create mode 100644 backend/services/notify_channel.go create mode 100644 backend/services/storage_update_test.go create mode 100644 frontend/src/api/alerts/index.ts create mode 100644 frontend/src/api/logs/index.ts create mode 100644 frontend/src/api/settings/index.ts create mode 100644 frontend/src/components/ResourceCard.tsx create mode 100644 frontend/src/pages/alerts/index.tsx create mode 100644 frontend/src/pages/collectors/components/CollectorEditDialog.tsx create mode 100644 frontend/src/pages/deployments/components/RetentionDialog.tsx create mode 100644 frontend/src/pages/logs/components/AccessRuleEditor.tsx create mode 100644 frontend/src/pages/logs/components/HighlightedLogLine.tsx create mode 100644 frontend/src/pages/logs/components/IngressInsightsTable.tsx create mode 100644 frontend/src/pages/logs/components/LogContextDialog.tsx create mode 100644 frontend/src/pages/logs/components/LogHistogram.tsx create mode 100644 frontend/src/pages/logs/components/LogResultViews.tsx create mode 100644 frontend/src/pages/logs/components/LogResults.tsx create mode 100644 frontend/src/pages/logs/components/LogResultsPanel.tsx create mode 100644 frontend/src/pages/logs/components/LogVolumeTimeline.tsx create mode 100644 frontend/src/pages/logs/components/LokiLabelBrowser.tsx create mode 100644 frontend/src/pages/logs/components/PolicyGroupsPage.tsx create mode 100644 frontend/src/pages/logs/components/QueryEmptyState.tsx create mode 100644 frontend/src/pages/logs/components/QueryInput.tsx create mode 100644 frontend/src/pages/logs/components/QuickQueryTemplates.tsx create mode 100644 frontend/src/pages/logs/components/SearchableValueSelect.tsx create mode 100644 frontend/src/pages/logs/components/TimeRangePicker.tsx create mode 100644 frontend/src/pages/logs/index.tsx create mode 100644 frontend/src/pages/logs/ingress-access-log.ts create mode 100644 frontend/src/pages/logs/ingress-insights.ts create mode 100644 frontend/src/pages/logs/query-history.ts create mode 100644 frontend/src/pages/logs/query-utils.ts create mode 100644 frontend/src/pages/settings/components/ImageRegistrySection.tsx create mode 100644 frontend/src/pages/settings/components/NotifySection.tsx create mode 100644 frontend/src/pages/settings/components/ThemeSection.tsx diff --git a/.gitignore b/.gitignore index d713647..b1cf02f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Logs -logs +/logs/ *.log # Backend build artifacts diff --git a/AGENTS.md b/AGENTS.md index f76865b..a30fb01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## 项目概览 opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana -Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。 +Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。 Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。 默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。 @@ -68,7 +68,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret 用 `models.SanitizedJSON(...)` 包装返回结果。 - `services/` — 业务逻辑层。部署流水线就在这里: `DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式 - (`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。 + (`k8s-yaml` / `host-binary` / `host-docker`)分发,Kubernetes 操作由 client-go 完成,主机操作通过 SSH。 其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`, 以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。 - `middleware/jwt.go` — `JWTAuth()`(解析后对照 JsonDB 校验 `TokenVersion`,并以数据库最新 @@ -130,12 +130,12 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret - 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与 `document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。 - 部署向导(`/wizard`):状态推导式步骤地图(preflight 端点聚合各资源状态),四步:环境与 - 目标 → 存储 → Loki(+Grafana)→ 采集;k8s 资源统一默认 `log` 命名空间(可改)。 - Grafana 为**可选**(默认关闭):k8s 用 NodePort(自动分配)+数据源 ConfigMap,主机用 tarball+systemd;平台已内置日志查询,通常无需 Grafana。 + 目标 → 存储 → Loki→ 采集;k8s 资源统一默认 `log` 命名空间(可改)。 + Grafana 为**可选**(默认关闭):Grafana 自动部署已移除,平台内置日志查询。 - **LDAP 接入**(参照 opsx-web-lite):设置页配置(SystemSetting 表 JSON,Bind 密码 AES 加密、 对外只回 `hasPassword`);启用后 ①登录支持 LDAP 用户验证并自动建号(authSource=ldap)②用户页 出现「从 LDAP 导入」(已导入置灰;本地同名转 LDAP);`/settings/ldap/{test,users}` 端点。 -- **日志查询**(`/logs`):数据源 `LogSources`(cluster-svc=经 kubectl --raw service proxy 查集群内 +- **日志查询**(`/logs`):数据源 `LogSources`(cluster-svc=经 client-go 认证连接 API Server Service Proxy 查集群内 Loki,不依赖 ClusterIP/DNS;url=直连;部署成功自动注册);`/logs/{query,labels,label/*/values,histogram}` 代理端点(cursor 翻页);前端 LogQL 高亮补全 + 直方图 + 标签点击过滤 + Live Tail(3s 轮询)。 - 用户管理页(`/users`,meta `adminOnly: true` — 菜单按角色过滤,后端 AdminAuth 兜底); @@ -151,8 +151,8 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret 在 Settings「关于」展示。 ## Loki 部署相关细节 -- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig); +- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。K8s 模式需要一个 Cluster(kubeconfig); `host-binary` 需要一个 SSH Host,会安装 Loki + systemd。 - Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) — 见 `services/deployment.go` 中的 `ValidateLokiVersion`。 -- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。 +- Docker 镜像无需安装 `kubectl` 或 `helm`。 diff --git a/CLAUDE.md b/CLAUDE.md index 280840d..208dc3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目概览 opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana -Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。 +Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。 Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。 默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。 @@ -67,7 +67,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret 用 `models.SanitizedJSON(...)` 包装返回结果。 - `services/` — 业务逻辑层。部署流水线就在这里: `DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式 - (`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。 + (`k8s-yaml` / `host-binary` / `host-docker`)分发,Kubernetes 操作由 client-go 完成,主机操作通过 SSH。 其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`, 以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。 - `middleware/jwt.go` — `JWTAuth()`(解析后对照 JsonDB 校验 `TokenVersion`,并以数据库最新 @@ -147,8 +147,8 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret 在 Settings「关于」展示。 ## Loki 部署相关细节 -- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig); +- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。K8s 模式需要一个 Cluster(kubeconfig); `host-binary` 需要一个 SSH Host,会安装 Loki + systemd。 - Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) — 见 `services/deployment.go` 中的 `ValidateLokiVersion`。 -- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。 +- Docker 镜像无需安装 `kubectl` 或 `helm`。 diff --git a/Dockerfile b/Dockerfile index 3de2197..a03c3a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,67 +55,14 @@ FROM alpine:3.20 # Install runtime dependencies: # ca-certificates: HTTPS TLS verification # openssh-client: SSH key auth for host deployment -# curl: kubectl/helm download + runtime checks # bash: Entrypoint script support # wget: HEALTHCHECK (lighter than curl) RUN apk --no-cache add \ ca-certificates \ openssh-client \ - curl \ bash \ wget -ARG TARGETARCH=amd64 - -# ---- Install kubectl with checksum verification ---- -# P1: curl downloads must have integrity verification -ENV KUBECTL_VERSION=v1.31.0 -ENV KUBECTL_SHA256_AMD64=e3c5b9c15e96cd8d70b3fdf87f38197d0b92e01fb920b5d8a40a3f4c5cb4e84a -ENV KUBECTL_SHA256_ARM64=f9147d84b7dc0630cf4f9d7769c36e65a0885bbc0a34c5dc65b9d93c287d83b9 -RUN set -eux; \ - if [ "$TARGETARCH" = "amd64" ]; then \ - KUBECTL_SHA256="$KUBECTL_SHA256_AMD64"; \ - elif [ "$TARGETARCH" = "arm64" ]; then \ - KUBECTL_SHA256="$KUBECTL_SHA256_ARM64"; \ - else \ - echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \ - fi; \ - curl -fsSL -o /tmp/kubectl \ - "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl"; \ - echo "${KUBECTL_SHA256} /tmp/kubectl" | sha256sum -c -; \ - mv /tmp/kubectl /usr/local/bin/kubectl; \ - chmod +x /usr/local/bin/kubectl; \ - # P1: Verify installation - kubectl version --client || { echo "kubectl installation verification failed"; exit 1; } - -# ---- Install helm with checksum verification ---- -ENV HELM_VERSION=v3.16.3 -# Helm checksums from official releases (https://github.com/helm/helm/releases) -# helm-v3.16.3-linux-amd64.tar.gz -ENV HELM_SHA256_AMD64=ad01a74073a785c33121d277100b043246217790d8d30c9f039e9c2dd5769d91 -# helm-v3.16.3-linux-arm64.tar.gz -ENV HELM_SHA256_ARM64=5bd34b755e58f27f1c4bc8edaef44152d0e7b9f5978e6d0aac394a3c81951498 -RUN set -eux; \ - HELM_TAR="helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz"; \ - if [ "$TARGETARCH" = "amd64" ]; then \ - HELM_SHA256="$HELM_SHA256_AMD64"; \ - elif [ "$TARGETARCH" = "arm64" ]; then \ - HELM_SHA256="$HELM_SHA256_ARM64"; \ - else \ - echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \ - fi; \ - curl -fsSL -o "/tmp/${HELM_TAR}" \ - "https://get.helm.sh/${HELM_TAR}"; \ - # P1: SHA256 integrity verification - echo "${HELM_SHA256} /tmp/${HELM_TAR}" | sha256sum -c -; \ - mkdir -p /tmp/helm; \ - tar -xzf "/tmp/${HELM_TAR}" -C /tmp/helm; \ - mv "/tmp/helm/linux-${TARGETARCH}/helm" /usr/local/bin/helm; \ - chmod +x /usr/local/bin/helm; \ - rm -rf /tmp/helm*; \ - # P1: Verify installation - helm version --client || { echo "helm installation verification failed"; exit 1; } - # --------------------------------------------------------------- # P0: Create non-root user and group # --------------------------------------------------------------- @@ -149,7 +96,6 @@ RUN rm -rf /tmp/* /var/cache/apk/* ENV DATA_DIR=/app/data ENV SERVER_PORT=8080 ENV KUBECONFIG=/home/opsxloki/.kube/config -# HOME must be set for kubectl/helm to find config ENV HOME=/home/opsxloki # NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here. diff --git a/backend/controllers/alerts.go b/backend/controllers/alerts.go new file mode 100644 index 0000000..2eb60c5 --- /dev/null +++ b/backend/controllers/alerts.go @@ -0,0 +1,283 @@ +package controllers + +import ( + "strconv" + "time" + + "opsxloki/dto" + "opsxloki/models" + "opsxloki/repository" + "opsxloki/services" + "opsxloki/utils" + + "github.com/gin-gonic/gin" +) + +// AlertsController 告警规则 + 飞书通知配置(参照 opsx-sec 通知 API 设计) +type AlertsController struct{} + +func NewAlertsController() *AlertsController { return &AlertsController{} } + +// ==================== 通知配置 ==================== + +// GetNotifyConfig 通知配置(只回 enabled/hasWebhook/hasSecret) +func (ctrl *AlertsController) GetNotifyConfig(c *gin.Context) { + cfg := services.GetNotifyConfig() + utils.Success(c, services.NotifyPublicConfig{ + Enabled: cfg.Enabled, HasWebhook: cfg.Webhook != "", HasSecret: cfg.SigningSecret != "", + }) +} + +// UpdateNotifyConfig 保存(webhook/签名留空=保留旧值) +func (ctrl *AlertsController) UpdateNotifyConfig(c *gin.Context) { + var req dto.NotifyConfigRequest + if !utils.BindAndValidate(c, &req) { + return + } + if err := services.SaveNotifyConfig(req.Enabled, req.Webhook, req.SigningSecret); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"message": "saved"}) +} + +// TestNotifyConfig 发送测试卡片(验证 webhook 连通) +func (ctrl *AlertsController) TestNotifyConfig(c *gin.Context) { + cfg := services.GetNotifyConfig() + if !cfg.Enabled || cfg.Webhook == "" { + utils.Error(c, 400, "请先保存并启用飞书通知") + return + } + if err := services.SendLarkCard("✅ opsx-loki 通知测试", "green", []string{ + "**时间**: " + time.Now().Format("2006-01-02 15:04:05"), + "**说明**: 管理员在设置页手动触发的连通性测试。", + }); err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, gin.H{"status": "sent"}) +} + +// ==================== 告警规则 CRUD ==================== + +func (ctrl *AlertsController) ListRules(c *gin.Context) { + db := repository.GetDB() + db.RLock() + out := make([]map[string]interface{}, 0, len(db.AlertRules)) + for i := range db.AlertRules { + out = append(out, db.AlertRules[i].Sanitized()) + } + db.RUnlock() + utils.Success(c, out) +} + +func (ctrl *AlertsController) CreateRule(c *gin.Context) { + var req dto.AlertRuleRequest + if !utils.BindAndValidate(c, &req) { + return + } + if req.WindowMin <= 0 { + req.WindowMin = 15 + } + if req.CooldownMin <= 0 { + req.CooldownMin = 60 + } + if req.Threshold < 0 { + req.Threshold = 0 + } + db := repository.GetDB() + rule := &models.AlertRule{ + ID: db.NextID("alertRules"), Name: req.Name, SourceID: req.SourceID, + ChannelIDs: req.ChannelIDs, + Query: req.Query, WindowMin: req.WindowMin, Threshold: req.Threshold, + Enabled: req.Enabled, CooldownMin: req.CooldownMin, + } + db.Lock() + db.AlertRules = append(db.AlertRules, *rule) + db.RequestSave() + db.Unlock() + utils.Success(c, rule.Sanitized()) +} + +func (ctrl *AlertsController) UpdateRule(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + var req map[string]interface{} + if !utils.BindAndValidate(c, &req) { + return + } + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.AlertRules { + if db.AlertRules[i].ID != uint(id) { + continue + } + if v, ok := req["name"].(string); ok && v != "" { + db.AlertRules[i].Name = v + } + if v, ok := req["query"].(string); ok && v != "" { + db.AlertRules[i].Query = v + } + if v, ok := req["sourceId"].(float64); ok && v > 0 { + db.AlertRules[i].SourceID = uint(v) + } + if arr, ok := req["channelIds"].([]interface{}); ok { + ids := make([]uint, 0, len(arr)) + for _, item := range arr { + if v, ok := item.(float64); ok && v > 0 { + ids = append(ids, uint(v)) + } + } + db.AlertRules[i].ChannelIDs = ids + } + if v, ok := req["windowMin"].(float64); ok && v > 0 { + db.AlertRules[i].WindowMin = int(v) + } + if v, ok := req["threshold"].(float64); ok && v >= 0 { + db.AlertRules[i].Threshold = int(v) + } + if v, ok := req["cooldownMin"].(float64); ok && v > 0 { + db.AlertRules[i].CooldownMin = int(v) + } + if v, ok := req["enabled"].(bool); ok { + db.AlertRules[i].Enabled = v + } + db.AlertRules[i].UpdatedAt = time.Now() + db.RequestSave() + utils.Success(c, db.AlertRules[i].Sanitized()) + return + } + utils.NotFound(c, "规则不存在") +} + +func (ctrl *AlertsController) DeleteRule(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.AlertRules { + if db.AlertRules[i].ID == uint(id) { + db.AlertRules = append(db.AlertRules[:i], db.AlertRules[i+1:]...) + db.RequestSave() + utils.Success(c, gin.H{"message": "deleted"}) + return + } + } + utils.NotFound(c, "规则不存在") +} + +// CheckNow 手动触发一次全量检查(不等定时器) +func (ctrl *AlertsController) CheckNow(c *gin.Context) { + services.RunAlertCheck(c.Request.Context()) + utils.Success(c, gin.H{"message": "checked"}) +} + +// Deliveries 发送历史(最近 50) +func (ctrl *AlertsController) Deliveries(c *gin.Context) { + db := repository.GetDB() + db.RLock() + out := make([]map[string]interface{}, 0, len(db.AlertDeliveries)) + for i := len(db.AlertDeliveries) - 1; i >= 0 && len(out) < 50; i-- { + d := db.AlertDeliveries[i] + out = append(out, map[string]interface{}{ + "id": d.ID, "ruleId": d.RuleID, "ruleName": d.RuleName, + "count": d.Count, "status": d.Status, "reason": d.Reason, "createdAt": d.CreatedAt, + }) + } + db.RUnlock() + utils.Success(c, out) +} + +// ==================== 通知通道(多 webhook) ==================== + +func (ctrl *AlertsController) ListChannels(c *gin.Context) { + utils.Success(c, services.ListNotifyChannels()) +} + +func (ctrl *AlertsController) SaveChannel(c *gin.Context) { + var req dto.NotifyChannelRequest + if !utils.BindAndValidate(c, &req) { + return + } + // PUT /channels/:id 时路径参数优先 + if pathID := c.Param("id"); pathID != "" { + if v, err := strconv.ParseUint(pathID, 10, 64); err == nil { + req.ID = uint(v) + } + } + ch, err := services.SaveNotifyChannel(req.ID, req.Name, req.Webhook, req.SigningSecret, req.Enabled) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, ch.Sanitized()) +} + +func (ctrl *AlertsController) DeleteChannel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + if err := services.DeleteNotifyChannel(uint(id)); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"message": "deleted"}) +} + +// TestChannel 向指定通道发送测试卡片(用表单参数或已存通道) +func (ctrl *AlertsController) TestChannel(c *gin.Context) { + var req dto.NotifyChannelRequest + if !utils.BindAndValidate(c, &req) { + return + } + // 测试已存通道时 name 可不传(沿用原名称);直测新 webhook 时缺省名 + name := req.Name + if req.ID > 0 { + if existing := services.GetNotifyChannel(req.ID); existing != nil && name == "" { + name = existing.Name + } + } else if name == "" { + name = "临时测试通道" + } + var channelID uint + if req.ID > 0 { + channelID = req.ID + // 表单 webhook 非空时先保存再测(未保存也可测新地址) + if req.Webhook != "" { + if _, err := services.SaveNotifyChannel(req.ID, name, req.Webhook, req.SigningSecret, true); err != nil { + utils.Error(c, 400, err.Error()) + return + } + } + } else if req.Webhook != "" { + // 未保存直测:保存后可删 + ch, err := services.SaveNotifyChannel(0, name, req.Webhook, req.SigningSecret, true) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + channelID = ch.ID + } + if channelID == 0 { + utils.BadRequest(c, "请提供通道 ID 或 webhook 地址") + return + } + if err := services.SendLarkCardToChannel(channelID, "✅ opsx-loki 通道测试", "green", []string{ + "**时间**: " + time.Now().Format("2006-01-02 15:04:05"), + "**说明**: 通道连通性测试。", + }); err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, gin.H{"status": "sent"}) +} diff --git a/backend/controllers/cluster.go b/backend/controllers/cluster.go index d3853bf..681b9c3 100644 --- a/backend/controllers/cluster.go +++ b/backend/controllers/cluster.go @@ -1,14 +1,58 @@ package controllers import ( + "context" "opsxloki/dto" "opsxloki/models" "opsxloki/services" "opsxloki/utils" + "time" "github.com/gin-gonic/gin" ) +func (ctrl *ClusterController) Namespaces(c *gin.Context) { + id, err := utils.IntParam(c, "id") + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + cluster, err := ctrl.service.Get(uint(id)) + if err != nil { + utils.NotFound(c, "集群不存在") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 12*time.Second) + defer cancel() + items, err := services.ListClusterNamespaces(ctx, cluster) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"namespaces": items}) +} + +func (ctrl *ClusterController) Deployments(c *gin.Context) { + id, err := utils.IntParam(c, "id") + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + cluster, err := ctrl.service.Get(uint(id)) + if err != nil { + utils.NotFound(c, "集群不存在") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 12*time.Second) + defer cancel() + items, err := services.ListClusterDeployments(ctx, cluster, c.Param("namespace")) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"deployments": items}) +} + type ClusterController struct { service *services.ClusterService } diff --git a/backend/controllers/deployment.go b/backend/controllers/deployment.go index 6e808dd..be88544 100644 --- a/backend/controllers/deployment.go +++ b/backend/controllers/deployment.go @@ -2,11 +2,11 @@ package controllers import ( "context" - "time" "opsxloki/dto" "opsxloki/models" "opsxloki/services" "opsxloki/utils" + "time" "github.com/gin-gonic/gin" ) @@ -31,6 +31,8 @@ func (ctrl *DeploymentController) Create(c *gin.Context) { Mode: req.Mode, ClusterID: req.ClusterID, HostID: req.HostID, + ListenIP: req.ListenIP, + ListenPort: req.ListenPort, Namespace: req.Namespace, StorageConfigID: req.StorageConfigID, AuthEnabled: req.AuthEnabled, @@ -120,6 +122,42 @@ func (ctrl *DeploymentController) Update(c *gin.Context) { utils.Success(c, gin.H{"message": "updated"}) } +// UpdateRetention 校验 YAML 片段,保存部署配置,并可立即重部署。 +func (ctrl *DeploymentController) UpdateRetention(c *gin.Context) { + id, err := utils.IntParam(c, "id") + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + var req dto.UpdateRetentionRequest + if !utils.BindAndValidate(c, &req) { + return + } + days, err := ctrl.service.UpdateRetention(uint(id), req.YAML, req.Apply) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"retentionDays": days, "applying": req.Apply}) +} + +// GetLiveConfig 返回运行中的完整 Loki YAML,服务层会先遮盖凭证。 +func (ctrl *DeploymentController) GetLiveConfig(c *gin.Context) { + id, err := utils.IntParam(c, "id") + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + result, err := ctrl.service.GetLiveConfig(ctx, uint(id)) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, result) +} + func (ctrl *DeploymentController) Delete(c *gin.Context) { id, err := utils.IntParam(c, "id") if err != nil { diff --git a/backend/controllers/image_registry.go b/backend/controllers/image_registry.go new file mode 100644 index 0000000..85a17a8 --- /dev/null +++ b/backend/controllers/image_registry.go @@ -0,0 +1,30 @@ +package controllers + +import ( + "opsxloki/dto" + "opsxloki/services" + "opsxloki/utils" + + "github.com/gin-gonic/gin" +) + +// ImageRegistryController 镜像源设置(国内/海外/私有仓库) +type ImageRegistryController struct{} + +func NewImageRegistryController() *ImageRegistryController { return &ImageRegistryController{} } + +func (ctrl *ImageRegistryController) Get(c *gin.Context) { + utils.Success(c, services.ImageRegistryPublicConfig()) +} + +func (ctrl *ImageRegistryController) Save(c *gin.Context) { + var req dto.ImageRegistryRequest + if !utils.BindAndValidate(c, &req) { + return + } + if err := services.SaveImageRegistry(req.Mode, req.CustomHost); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, services.ImageRegistryPublicConfig()) +} diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 6b58607..6b54e1f 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -2,7 +2,6 @@ package controllers import ( "context" - "encoding/json" "strconv" "strings" "time" @@ -30,27 +29,23 @@ func (ctrl *LogsController) Context(c *gin.Context) { utils.BadRequest(c, "时间戳无效") return } - parts := make([]string, 0, len(req.Labels)) - for key, value := range req.Labels { - if !services.ValidLogLabelName(key) { - utils.BadRequest(c, "标签名无效") - return - } - encoded, _ := json.Marshal(value) - parts = append(parts, key+"="+string(encoded)) + selector, err := services.ContextLogSelector(req.Labels) + if err != nil { + utils.BadRequest(c, err.Error()) + return } - query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{"+strings.Join(parts, ",")+"}") + query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", selector) if err != nil { utils.Forbidden(c, err.Error()) return } span := int64(5 * time.Minute) - before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 1, "backward", "") + before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 5, "backward", "") if err != nil { utils.Error(c, 502, err.Error()) return } - after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 1, "forward", "") + after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 5, "forward", "") if err != nil { utils.Error(c, 502, err.Error()) return @@ -118,6 +113,10 @@ func (ctrl *LogsController) UpdateSource(c *gin.Context) { if !utils.BindAndValidate(c, &updates) { return } + if src := services.GetLogSource(uint(id)); src != nil && src.Managed && updates["isDefault"] == nil { + utils.BadRequest(c, "平台部署的数据源由系统管理,不可编辑") + return + } if err := services.UpdateLogSource(uint(id), updates); err != nil { utils.Error(c, 400, err.Error()) return @@ -131,6 +130,10 @@ func (ctrl *LogsController) DeleteSource(c *gin.Context) { utils.BadRequest(c, "ID 无效") return } + if src := services.GetLogSource(uint(id)); src != nil && src.Managed { + utils.BadRequest(c, "平台部署的数据源随部署管理,请先删除对应部署") + return + } if err := services.DeleteLogSource(uint(id)); err != nil { utils.Error(c, 400, err.Error()) return diff --git a/backend/controllers/storage.go b/backend/controllers/storage.go index afcce7c..ed9e867 100644 --- a/backend/controllers/storage.go +++ b/backend/controllers/storage.go @@ -104,13 +104,13 @@ func (ctrl *StorageController) TestConnection(c *gin.Context) { return } result := services.TestStorageConnection(services.StorageTestParams{ - StorageType: req.StorageType, - Endpoint: req.Endpoint, - Bucket: req.Bucket, - Region: req.Region, - AccessKey: req.AccessKey, - SecretKey: req.SecretKey, - Insecure: req.Insecure, + StorageType: req.StorageType, + Endpoint: req.Endpoint, + Bucket: req.Bucket, + Region: req.Region, + AccessKey: req.AccessKey, + SecretKey: req.SecretKey, + Insecure: req.Insecure, CreateIfMissing: req.CreateIfMissing, }) utils.Success(c, result) diff --git a/backend/dto/alerts.go b/backend/dto/alerts.go new file mode 100644 index 0000000..f2dbda2 --- /dev/null +++ b/backend/dto/alerts.go @@ -0,0 +1,27 @@ +package dto + +// 通知与告警域 +type NotifyConfigRequest struct { + Enabled bool `json:"enabled"` + Webhook string `json:"webhook" binding:"omitempty,url"` + SigningSecret string `json:"signingSecret"` +} + +type AlertRuleRequest struct { + Name string `json:"name" binding:"required"` + SourceID uint `json:"sourceId" binding:"required"` + ChannelIDs []uint `json:"channelIds"` + Query string `json:"query" binding:"required"` + WindowMin int `json:"windowMin"` + Threshold int `json:"threshold"` + Enabled bool `json:"enabled"` + CooldownMin int `json:"cooldownMin"` +} + +type NotifyChannelRequest struct { + ID uint `json:"id"` + Name string `json:"name"` + Webhook string `json:"webhook" binding:"omitempty,url"` + SigningSecret string `json:"signingSecret"` + Enabled bool `json:"enabled"` +} diff --git a/backend/dto/deployment.go b/backend/dto/deployment.go index f6dff96..82ad5c0 100644 --- a/backend/dto/deployment.go +++ b/backend/dto/deployment.go @@ -5,9 +5,11 @@ type CreateDeploymentRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Version string `json:"version"` - Mode string `json:"mode" binding:"required,oneof=k8s-helm k8s-yaml host-binary"` + Mode string `json:"mode" binding:"required,oneof=k8s-yaml host-binary host-docker"` ClusterID *uint `json:"clusterID"` HostID *uint `json:"hostID"` + ListenIP string `json:"listenIP"` + ListenPort int `json:"listenPort"` Namespace string `json:"namespace"` StorageConfigID uint `json:"storageConfigID"` AuthEnabled bool `json:"authEnabled"` @@ -19,3 +21,9 @@ type CreateDeploymentRequest struct { GrafanaEnabled bool `json:"grafanaEnabled"` GrafanaVersion string `json:"grafanaVersion"` } + +// UpdateRetentionRequest 编辑 Loki limits_config 中的保留策略片段。 +type UpdateRetentionRequest struct { + YAML string `json:"yaml" binding:"required"` + Apply bool `json:"apply"` +} diff --git a/backend/dto/image_registry.go b/backend/dto/image_registry.go new file mode 100644 index 0000000..effd5ae --- /dev/null +++ b/backend/dto/image_registry.go @@ -0,0 +1,6 @@ +package dto + +type ImageRegistryRequest struct { + Mode string `json:"mode" binding:"required,oneof=overseas cn custom"` + CustomHost string `json:"customHost"` +} diff --git a/backend/go.mod b/backend/go.mod index c0e4843..d117a82 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,10 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 golang.org/x/crypto v0.47.0 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.33.4 + k8s.io/apimachinery v0.33.4 + k8s.io/client-go v0.33.4 ) require ( @@ -20,28 +24,57 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.7 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/goccy/go-json v0.10.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.5.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index aebc5e7..c344a2b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -16,6 +18,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns= @@ -28,6 +34,16 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -36,15 +52,26 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -59,67 +86,156 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.33.4 h1:oTzrFVNPXBjMu0IlpA2eDDIU49jsuEorGHB4cvKupkk= +k8s.io/api v0.33.4/go.mod h1:VHQZ4cuxQ9sCUMESJV5+Fe8bGnqAARZ08tSTdHWfeAc= +k8s.io/apimachinery v0.33.4 h1:SOf/JW33TP0eppJMkIgQ+L6atlDiP/090oaX0y9pd9s= +k8s.io/apimachinery v0.33.4/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/client-go v0.33.4 h1:TNH+CSu8EmXfitntjUPwaKVPN0AYMbc9F1bBS8/ABpw= +k8s.io/client-go v0.33.4/go.mod h1:LsA0+hBG2DPwovjd931L/AoaezMPX9CmBgyVyBZmbCY= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= +sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/backend/main.go b/backend/main.go index 3c98e82..6695cc1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -13,6 +13,7 @@ import ( "opsxloki/repository" "opsxloki/routers" "opsxloki/seed" + "opsxloki/services" ) func main() { @@ -30,12 +31,15 @@ func main() { // 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。 repository.GetDB().RecoverOrphanDeployments() + services.StartAlertEngine() + defer services.StopAlertEngine() + r := routers.SetupRouter() srv := &http.Server{ Addr: ":" + cfg.ServerPort, Handler: r, ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, + WriteTimeout: 130 * time.Second, IdleTimeout: 120 * time.Second, } diff --git a/backend/models/alert.go b/backend/models/alert.go new file mode 100644 index 0000000..3cc7c82 --- /dev/null +++ b/backend/models/alert.go @@ -0,0 +1,42 @@ +package models + +import "time" + +// AlertRule 日志告警规则:对选定数据源周期执行 LogQL 计数,超过阈值即通知。 +// 参照运维侧 app-alert.py 的两类模式: +// +// critical(出现即报,threshold=1)/ surge(突增才报,自定义阈值),统一收敛为「N 条 / 窗口 > 阈值」。 +type AlertRule struct { + ID uint `json:"id"` + Name string `json:"name"` + SourceID uint `json:"sourceId"` // LogSource + ChannelIDs []uint `json:"channelIds"` // NotifyChannel 列表(告警扇出到多个群) + Query string `json:"query"` // 完整 LogQL({app="x"} |= "FATAL") + WindowMin int `json:"windowMin"` // 统计窗口(分钟) + Threshold int `json:"threshold"` // 窗口内条数 > 阈值触发 + Enabled bool `json:"enabled"` + CooldownMin int `json:"cooldownMin"` // 同规则告警冷却(分钟,默认 60) + LastFiredAt time.Time `json:"lastFiredAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (r *AlertRule) Sanitized() map[string]interface{} { + return map[string]interface{}{ + "id": r.ID, "name": r.Name, "sourceId": r.SourceID, "channelIds": r.ChannelIDs, + "query": r.Query, "windowMin": r.WindowMin, "threshold": r.Threshold, + "enabled": r.Enabled, "cooldownMin": r.CooldownMin, + "lastFiredAt": r.LastFiredAt, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, + } +} + +// AlertDelivery 告警发送历史(飞书 webhook 投递记录) +type AlertDelivery struct { + ID uint `json:"id"` + RuleID uint `json:"ruleId"` + RuleName string `json:"ruleName"` + Count int `json:"count"` // 窗口内命中条数 + Status string `json:"status"` // sent | failed | cooldown-skipped + Reason string `json:"reason"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/models/deployment.go b/backend/models/deployment.go index bf57c6a..b606ae6 100644 --- a/backend/models/deployment.go +++ b/backend/models/deployment.go @@ -17,6 +17,8 @@ type LokiDeployment struct { Cluster *Cluster `json:"cluster,omitempty"` HostID *uint `json:"hostID,omitempty"` Host *Host `json:"host,omitempty"` + ListenIP string `json:"listenIP,omitempty"` + ListenPort int `json:"listenPort,omitempty"` Namespace string `json:"namespace"` StorageConfigID uint `json:"storageConfigID"` StorageConfig *StorageConfig `json:"storageConfig,omitempty"` @@ -58,6 +60,8 @@ func (d *LokiDeployment) Sanitized() map[string]interface{} { "mode": d.Mode, "clusterID": d.ClusterID, "hostID": d.HostID, + "listenIP": d.ListenIP, + "listenPort": d.ListenPort, "namespace": d.Namespace, "storageConfigID": d.StorageConfigID, "authEnabled": d.AuthEnabled, diff --git a/backend/models/log_source.go b/backend/models/log_source.go index 76e835b..10076bb 100644 --- a/backend/models/log_source.go +++ b/backend/models/log_source.go @@ -24,6 +24,7 @@ type LogSource struct { Headers map[string]string `json:"headers,omitempty"` SkipTLSVerify bool `json:"skipTLSVerify"` IsDefault bool `json:"isDefault"` + Managed bool `json:"managed"` // 平台部署自动注册(固定不可编辑) Access []LogSourceAccess `json:"access,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -34,7 +35,7 @@ func (s *LogSource) Sanitized() map[string]interface{} { "id": s.ID, "name": s.Name, "targetType": s.TargetType, "clusterID": s.ClusterID, "namespace": s.Namespace, "service": s.Service, "port": s.Port, "url": s.URL, "authType": s.AuthType, "basicUser": s.BasicUser, - "hasBasicPass": s.BasicPass != "", "isDefault": s.IsDefault, + "hasBasicPass": s.BasicPass != "", "isDefault": s.IsDefault, "managed": s.Managed, "hasBearerToken": s.BearerToken != "", "headerNames": headerNames(s.Headers), "skipTLSVerify": s.SkipTLSVerify, "access": s.Access, "createdAt": s.CreatedAt, "updatedAt": s.UpdatedAt, diff --git a/backend/models/notify_channel.go b/backend/models/notify_channel.go new file mode 100644 index 0000000..23e7e4f --- /dev/null +++ b/backend/models/notify_channel.go @@ -0,0 +1,23 @@ +package models + +import "time" + +// NotifyChannel 通知通道:一个飞书/Lark 群机器人 webhook。 +// 支持多通道对应不同告警群(如「应用告警群」「Nginx 告警群」),告警规则各自关联。 +type NotifyChannel struct { + ID uint `json:"id"` + Name string `json:"name"` + Webhook string `json:"webhook"` // ENC: 加密存储,不外发 + SigningSecret string `json:"signingSecret"` // ENC: 可选签名校验 + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (c *NotifyChannel) Sanitized() map[string]interface{} { + return map[string]interface{}{ + "id": c.ID, "name": c.Name, + "hasWebhook": c.Webhook != "", "hasSecret": c.SigningSecret != "", + "enabled": c.Enabled, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt, + } +} diff --git a/backend/models/storage.go b/backend/models/storage.go index a4507d1..6ea72d1 100644 --- a/backend/models/storage.go +++ b/backend/models/storage.go @@ -20,16 +20,17 @@ type StorageConfig struct { func (s *StorageConfig) Sanitized() map[string]interface{} { return map[string]interface{}{ - "id": s.ID, - "name": s.Name, - "storageType": s.StorageType, - "endpoint": s.Endpoint, - "bucket": s.Bucket, - "region": s.Region, - "pathPrefix": s.PathPrefix, - "insecure": s.Insecure, - "isDefault": s.IsDefault, - "createdAt": s.CreatedAt, - "updatedAt": s.UpdatedAt, + "id": s.ID, + "name": s.Name, + "storageType": s.StorageType, + "endpoint": s.Endpoint, + "bucket": s.Bucket, + "region": s.Region, + "pathPrefix": s.PathPrefix, + "insecure": s.Insecure, + "isDefault": s.IsDefault, + "hasSecretKey": s.SecretKey != "", + "createdAt": s.CreatedAt, + "updatedAt": s.UpdatedAt, } } diff --git a/backend/repository/db.go b/backend/repository/db.go index 41b6eb7..0a08906 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -31,6 +31,9 @@ type JsonDB struct { Logs []models.DeploymentLog `json:"logs"` Settings []models.SystemSetting `json:"settings"` LogSources []models.LogSource `json:"logSources"` + AlertRules []models.AlertRule `json:"alertRules"` + AlertDeliveries []models.AlertDelivery `json:"alertDeliveries"` + NotifyChannels []models.NotifyChannel `json:"notifyChannels"` LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"` sequences map[string]uint @@ -63,6 +66,9 @@ func InitDB() error { Logs: []models.DeploymentLog{}, Settings: []models.SystemSetting{}, LogSources: []models.LogSource{}, + AlertRules: []models.AlertRule{}, + AlertDeliveries: []models.AlertDelivery{}, + NotifyChannels: []models.NotifyChannel{}, LogPolicyGroups: []models.LogPolicyGroup{}, sequences: map[string]uint{ "users": 0, diff --git a/backend/routers/router.go b/backend/routers/router.go index d750da2..97a4da5 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -34,6 +34,8 @@ func SetupRouter() *gin.Engine { userCtrl := controllers.NewUserController() ldapCtrl := controllers.NewLdapController() logsCtrl := controllers.NewLogsController() + alertsCtrl := controllers.NewAlertsController() + registryCtrl := controllers.NewImageRegistryController() // API v1 api := r.Group("/api/v1") @@ -86,6 +88,29 @@ func SetupRouter() *gin.Engine { logs.GET("/health", logsCtrl.Health) } + // 镜像源(国内加速/海外/私有仓库) + authorized.GET("/settings/image-registry", registryCtrl.Get) + authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save) + + // 告警:飞书通知配置 + 告警规则(参照 opsx-sec) + alertNotify := authorized.Group("/alerts") + { + alertNotify.GET("/notify-config", alertsCtrl.GetNotifyConfig) + alertNotify.PUT("/notify-config", middleware.AdminAuth(), alertsCtrl.UpdateNotifyConfig) + alertNotify.POST("/notify-config/test", middleware.AdminAuth(), alertsCtrl.TestNotifyConfig) + alertNotify.GET("/rules", alertsCtrl.ListRules) + alertNotify.POST("/rules", middleware.AdminAuth(), alertsCtrl.CreateRule) + alertNotify.PUT("/rules/:id", middleware.AdminAuth(), alertsCtrl.UpdateRule) + alertNotify.DELETE("/rules/:id", middleware.AdminAuth(), alertsCtrl.DeleteRule) + alertNotify.POST("/check", middleware.AdminAuth(), alertsCtrl.CheckNow) + alertNotify.GET("/deliveries", alertsCtrl.Deliveries) + alertNotify.GET("/channels", alertsCtrl.ListChannels) + alertNotify.POST("/channels", middleware.AdminAuth(), alertsCtrl.SaveChannel) + alertNotify.PUT("/channels/:id", middleware.AdminAuth(), alertsCtrl.SaveChannel) + alertNotify.DELETE("/channels/:id", middleware.AdminAuth(), alertsCtrl.DeleteChannel) + alertNotify.POST("/channels/test", middleware.AdminAuth(), alertsCtrl.TestChannel) + } + // LDAP 配置(admin 读写;查询供用户页判断导入按钮显隐,登录用户即可读) authorized.GET("/settings/ldap", middleware.AdminAuth(), ldapCtrl.GetConfig) authorized.PUT("/settings/ldap", middleware.AdminAuth(), ldapCtrl.UpdateConfig) @@ -113,6 +138,8 @@ func SetupRouter() *gin.Engine { authorized.DELETE("/clusters/:id", clusterCtrl.Delete) authorized.POST("/clusters/:id/validate", clusterCtrl.Validate) authorized.GET("/clusters/:id/info", clusterCtrl.GetInfo) + authorized.GET("/clusters/:id/namespaces", clusterCtrl.Namespaces) + authorized.GET("/clusters/:id/namespaces/:namespace/deployments", clusterCtrl.Deployments) // Hosts authorized.GET("/hosts", hostCtrl.List) @@ -138,6 +165,8 @@ func SetupRouter() *gin.Engine { authorized.GET("/deployments/:id", deploymentCtrl.Get) authorized.POST("/deployments", deploymentCtrl.Create) authorized.PUT("/deployments/:id", deploymentCtrl.Update) + authorized.POST("/deployments/:id/retention", deploymentCtrl.UpdateRetention) + authorized.GET("/deployments/:id/config", middleware.AdminAuth(), deploymentCtrl.GetLiveConfig) authorized.DELETE("/deployments/:id", deploymentCtrl.Delete) authorized.POST("/deployments/:id/deploy", deploymentCtrl.Deploy) authorized.POST("/deployments/:id/stop", deploymentCtrl.Stop) diff --git a/backend/seed/alert_rules.go b/backend/seed/alert_rules.go new file mode 100644 index 0000000..27b4404 --- /dev/null +++ b/backend/seed/alert_rules.go @@ -0,0 +1,83 @@ +package seed + +// 内置告警规则 — 新环境开箱即用的通用日志告警(参照运维告警脚本的两类模式): +// 致命类(出现即报):FATAL / panic / OutOfMemoryError / StackOverflowError +// 突增类(超阈值才报):应用 error 过多 / ingress-nginx 4xx / 5xx +// 绑定数据源:部署成功自动注册的 LogSource(managed)。 +// 规则默认不启用(通知通道由管理员后期在设置页配置后再开启),ChannelIDs 留空。 +import ( + "time" + + "opsxloki/models" + "opsxloki/repository" +) + +type builtinRule struct { + name string + query string + windowMin int + threshold int + cooldown int +} + +var builtinRules = []builtinRule{ + // —— 致命类:15 分钟内出现即报 —— + {name: "致命日志 FATAL", query: `{namespace=~".+"} |= "FATAL"`, windowMin: 15, threshold: 0, cooldown: 60}, + {name: "Go panic", query: `{namespace=~".+"} |= "panic:"`, windowMin: 15, threshold: 0, cooldown: 60}, + {name: "JVM OutOfMemoryError", query: `{namespace=~".+"} |~ "(^|[^a-zA-Z])OutOfMemoryError"`, windowMin: 15, threshold: 0, cooldown: 60}, + {name: "StackOverflowError", query: `{namespace=~".+"} |= "StackOverflowError"`, windowMin: 15, threshold: 0, cooldown: 60}, + {name: "K8s 节点 NotReady", query: `{app="kube-state-metrics"} |= "NotReady"`, windowMin: 15, threshold: 0, cooldown: 120}, + {name: "容器 OOMKilled", query: `{app=~"kube-state-metrics|event-exporter"} |= "OOMKilled"`, windowMin: 15, threshold: 0, cooldown: 120}, + // —— 突增类:超过基线阈值才报 —— + {name: "应用错误过多(error > 1000)", query: `{namespace=~".+"} |~ "(?i)\berror\b"`, windowMin: 15, threshold: 1000, cooldown: 60}, + {name: "ingress-nginx 5xx 错误", query: `{app=~"ingress-nginx|nginx-ingress-controller"} |~ " (50[0-4]|5[2-9][0-9]) `, windowMin: 15, threshold: 100, cooldown: 60}, + {name: "ingress-nginx 4xx 激增", query: `{app=~"ingress-nginx|nginx-ingress-controller"} |~ " 4[0-9][0-9] "`, windowMin: 15, threshold: 1000, cooldown: 60}, + {name: "数据库连接失败", query: `{namespace=~".+"} |~ "(?i)(connection refused|connection reset by peer|too many connections)"`, windowMin: 15, threshold: 50, cooldown: 60}, +} + +// seedBuiltinAlertRules 为每个平台管理的 LogSource(managed=true)补齐内置规则: +// 首次部署 Loki 后即有一套可直接启用的告警模板;通知通道留空,管理员配置后启用。 +// SeedAlertRules 补齐内置告警规则(幂等;公开给启动与部署成功两处调用) +func SeedAlertRules() { + db := repository.GetDB() + db.RLock() + type srcRef struct { + id uint + name string + } + var sources []srcRef + for i := range db.LogSources { + if db.LogSources[i].Managed { + sources = append(sources, srcRef{id: db.LogSources[i].ID, name: db.LogSources[i].Name}) + } + } + // 已有规则(按 name)跳过 + existing := map[string]bool{} + for i := range db.AlertRules { + existing[db.AlertRules[i].Name] = true + } + db.RUnlock() + + if len(sources) == 0 { + return + } + // 绑定第一个 managed 数据源(通常是唯一部署) + target := sources[0] + + newID := db.NextID("alertRules") + db.Lock() + defer db.Unlock() + for _, r := range builtinRules { + if existing[r.name] { + continue + } + newID++ + db.AlertRules = append(db.AlertRules, models.AlertRule{ + ID: newID, Name: r.name, SourceID: target.id, + Query: r.query, WindowMin: r.windowMin, Threshold: r.threshold, + Enabled: false, CooldownMin: r.cooldown, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + }) + } + db.RequestSave() +} diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 920d35b..24979e1 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -21,4 +21,7 @@ func Seed() { Role: "admin", }) } + + // 内置告警规则:有 managed 数据源(部署过 Loki)时补齐通用模板(默认禁用) + SeedAlertRules() } diff --git a/backend/services/alert.go b/backend/services/alert.go new file mode 100644 index 0000000..2031843 --- /dev/null +++ b/backend/services/alert.go @@ -0,0 +1,265 @@ +package services + +// 日志告警引擎:周期执行 AlertRule 的 LogQL 计数查询,超阈值发送飞书卡片。 +// 设计参照运维 app-alert.py:即时查询 sum(count_over_time(q[窗口])) 精确计数 + +// 按 app/pod 分组定位 + 日志样例 + 告警冷却(同规则冷却期内不重复发)。 +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "opsxloki/models" + "opsxloki/repository" +) + +const alertCheckInterval = time.Minute + +var alertTicker *time.Ticker + +// StartAlertEngine 启动周期检查(main 调用) +func StartAlertEngine() { + if alertTicker != nil { + return + } + alertTicker = time.NewTicker(alertCheckInterval) + go func() { + for range alertTicker.C { + RunAlertCheck(context.Background()) + } + }() +} + +// StopAlertEngine 优雅关停 +func StopAlertEngine() { + if alertTicker != nil { + alertTicker.Stop() + alertTicker = nil + } +} + +// lokiInstantCount 即时计数:sum(count_over_time(q[window])) +func lokiInstantCount(ctx context.Context, src *models.LogSource, query string, windowMin int) (int, error) { + wrapped := fmt.Sprintf("sum(count_over_time(%s[%dm]))", query, windowMin) + q := url.Values{} + q.Set("query", wrapped) + q.Set("time", fmt.Sprintf("%d", time.Now().UnixNano())) + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q) + if err != nil { + return 0, err + } + var resp struct { + Data struct { + Result []struct { + Value [2]interface{} `json:"value"` + } `json:"result"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return 0, err + } + if len(resp.Data.Result) == 0 { + return 0, nil + } + count := 0 + switch v := resp.Data.Result[0].Value[1].(type) { + case string: + fmt.Sscanf(v, "%d", &count) + case float64: + count = int(v) + } + return count, nil +} + +// lokiGroupTop 按 app/pod 分组定位(取前 10) +func lokiGroupTop(ctx context.Context, src *models.LogSource, query string, windowMin int) []string { + wrapped := fmt.Sprintf("sum by (app, pod) (count_over_time(%s[%dm]))", query, windowMin) + q := url.Values{} + q.Set("query", wrapped) + q.Set("time", fmt.Sprintf("%d", time.Now().UnixNano())) + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q) + if err != nil { + return nil + } + var resp struct { + Data struct { + Result []struct { + Metric map[string]string `json:"metric"` + Value [2]interface{} `json:"value"` + } `json:"result"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil + } + lines := []string{} + for _, r := range resp.Data.Result { + count := 0 + if v, ok := r.Value[1].(string); ok { + fmt.Sscanf(v, "%d", &count) + } else if v, ok := r.Value[1].(float64); ok { + count = int(v) + } + app := r.Metric["app"] + if app == "" { + app = "?" + } + pod := r.Metric["pod"] + if pod == "" { + pod = "?" + } + lines = append(lines, fmt.Sprintf("- %s / %s(%d 条)", app, pod, count)) + if len(lines) >= 10 { + break + } + } + return lines +} + +// lokiSampleLines 日志样例(最多 3 条,每条截 220) +func lokiSampleLines(ctx context.Context, src *models.LogSource, query string, windowMin int) []string { + res, err := QueryLokiLogs(ctx, src, query, 0, 0, 3, "backward", "") + if err != nil { + return nil + } + out := []string{} + for _, e := range res.Entries { + line := e.Message + if len(line) > 220 { + line = line[:220] + } + out = append(out, "> "+line) + if len(out) >= 3 { + break + } + } + return out +} + +func recordDelivery(ruleID uint, ruleName string, count int, status, reason string) { + db := repository.GetDB() + newID := db.NextID("alertDeliveries") + db.Lock() + db.AlertDeliveries = append(db.AlertDeliveries, models.AlertDelivery{ + ID: newID, RuleID: ruleID, RuleName: ruleName, + Count: count, Status: status, Reason: reason, + }) + // 只保留最近 50 条 + if len(db.AlertDeliveries) > 50 { + db.AlertDeliveries = db.AlertDeliveries[len(db.AlertDeliveries)-50:] + } + db.RequestSave() + db.Unlock() +} + +// RunAlertCheck 执行一次全部启用规则的检查(定时器与手动触发共用) +func RunAlertCheck(ctx context.Context) { + db := repository.GetDB() + db.RLock() + rules := make([]models.AlertRule, 0, len(db.AlertRules)) + for i := range db.AlertRules { + if db.AlertRules[i].Enabled { + rules = append(rules, db.AlertRules[i]) + } + } + db.RUnlock() + + for i := range rules { + checkRule(ctx, &rules[i]) + } +} + +func checkRule(ctx context.Context, rule *models.AlertRule) { + src := GetLogSource(rule.SourceID) + if src == nil { + return + } + count, err := lokiInstantCount(ctx, src, rule.Query, rule.WindowMin) + if err != nil { + recordDelivery(rule.ID, rule.Name, 0, "failed", err.Error()) + return + } + if count <= rule.Threshold { + return + } + + // 冷却检查(同规则) + db := repository.GetDB() + db.RLock() + lastFired := rule.LastFiredAt + cooldown := rule.CooldownMin + if cooldown <= 0 { + cooldown = 60 + } + db.RUnlock() + if !lastFired.IsZero() && time.Since(lastFired) < time.Duration(cooldown)*time.Minute { + recordDelivery(rule.ID, rule.Name, count, "cooldown", "冷却中,未重复发送") + return + } + + // 组装卡片 + groups := lokiGroupTop(ctx, src, rule.Query, rule.WindowMin) + samples := lokiSampleLines(ctx, src, rule.Query, rule.WindowMin) + ts := time.Now().Format("2006-01-02 15:04:05") + lines := []string{ + fmt.Sprintf("**告警时间**: %s", ts), + fmt.Sprintf("**规则**: %s", rule.Name), + fmt.Sprintf("**命中数量**: %d 条(%d 分钟内)", count, rule.WindowMin), + fmt.Sprintf("**触发阈值**: > %d 条", rule.Threshold), + fmt.Sprintf("**查询**: `%s`", rule.Query), + } + if len(groups) > 0 { + lines = append(lines, "**来源 app/pod**:\n"+strings.Join(groups, "\n")) + } + if len(samples) > 0 { + lines = append(lines, "**日志样例**:\n"+strings.Join(samples, "\n")) + } + + // 扇出到全部关联通道;部分失败记录失败原因(成功任一即更新冷却) + title := "🔴 日志告警 " + rule.Name + var failMsgs []string + if len(rule.ChannelIDs) > 0 { + for _, chID := range rule.ChannelIDs { + if err := SendLarkCardToChannel(chID, title, "red", lines); err != nil { + chName := fmt.Sprintf("通道%d", chID) + if ch := GetNotifyChannel(chID); ch != nil { + chName = ch.Name + } + failMsgs = append(failMsgs, fmt.Sprintf("%s: %v", chName, err)) + } + } + } else if cfg := GetNotifyConfig(); cfg.Enabled && cfg.Webhook != "" { + // 兼容:未关联通道回退旧全局配置 + if err := SendLarkCard(title, "red", lines); err != nil { + failMsgs = append(failMsgs, err.Error()) + } + } else { + failMsgs = append(failMsgs, "规则未关联通知通道") + } + if len(failMsgs) > 0 { + recordDelivery(rule.ID, rule.Name, count, "failed", strings.Join(failMsgs, "; ")) + return + } + recordDelivery(rule.ID, rule.Name, count, "sent", fmt.Sprintf("已扇出到 %d 个通道", maxInt(len(rule.ChannelIDs), 1))) + + // 更新 LastFiredAt + db.Lock() + for i := range db.AlertRules { + if db.AlertRules[i].ID == rule.ID { + db.AlertRules[i].LastFiredAt = time.Now() + break + } + } + db.RequestSave() + db.Unlock() + recordDelivery(rule.ID, rule.Name, count, "sent", "") +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/backend/services/cluster.go b/backend/services/cluster.go index 3929d76..23e54ae 100644 --- a/backend/services/cluster.go +++ b/backend/services/cluster.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "opsxloki/pkg/crypto" "os" "path/filepath" "time" @@ -143,6 +144,13 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error { if v, ok := updates["apiServer"].(string); ok { db.Clusters[i].APIServer = v } + // kubeconfig 编辑约定:非空才更新(留空=保留旧密文) + if v, ok := updates["kubeconfig"].(string); ok && v != "" { + if enc, err := crypto.EncryptField(v); err == nil { + db.Clusters[i].KubeConfig = enc + db.Clusters[i].KubeConfigPath = "" + } + } // 凭据字段:空值不覆盖,避免编辑其它项时清空 if v, ok := updates["kubeconfig"].(string); ok && v != "" { db.Clusters[i].KubeConfig = v diff --git a/backend/services/cluster_info.go b/backend/services/cluster_info.go index 0aaddcb..e72fc4c 100644 --- a/backend/services/cluster_info.go +++ b/backend/services/cluster_info.go @@ -2,13 +2,13 @@ package services import ( "context" - "encoding/json" "fmt" - "os" - "os/exec" - "strings" - + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" "opsxloki/models" + "sort" + "strings" + "time" ) type ClusterInfo struct { @@ -30,72 +30,21 @@ type NodeInfo struct { Labels map[string]string `json:"labels"` } -// GetClusterInfo 通过 kubectl 查询集群信息。 -// Token 接入的集群在创建时已构造等价 kubeconfig,因此 kubectl 同样适用。 +// GetClusterInfo 从 API Server 读取集群信息,context 列表由 kubeconfig 本地解析。 func GetClusterInfo(ctx context.Context, cluster *models.Cluster) (*ClusterInfo, error) { - kubeconfig, err := resolveKubeconfig(cluster) + client, err := clusterClient(cluster, 30*time.Second) if err != nil { return nil, err } - - kubeFile, err := os.CreateTemp("", "kube-*.yaml") + info := &ClusterInfo{Nodes: []NodeInfo{}, Namespaces: []string{}, Contexts: []string{}} + if version, err := client.Discovery().ServerVersion(); err == nil { + info.Version = version.GitVersion + } + nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) if err != nil { - return nil, err + return nil, fmt.Errorf("获取节点列表失败: %w", err) } - defer os.Remove(kubeFile.Name()) - kubeFile.WriteString(kubeconfig) - kubeFile.Close() - - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name())) - - info := &ClusterInfo{ - Nodes: []NodeInfo{}, - Namespaces: []string{}, - Contexts: []string{}, - } - - // Version(best-effort) - if versionOut, err := runCmd(ctx, env, "kubectl", "version", "-o", "json"); err == nil { - var versionData struct { - ServerVersion struct { - GitVersion string `json:"gitVersion"` - } `json:"serverVersion"` - } - json.Unmarshal(versionOut, &versionData) - info.Version = versionData.ServerVersion.GitVersion - } - - // Nodes - nodesOut, err := runCmd(ctx, env, "kubectl", "get", "nodes", "-o", "json") - if err != nil { - return nil, fmt.Errorf("获取节点列表失败: %v", err) - } - var nodesData struct { - Items []struct { - Metadata struct { - Name string `json:"name"` - Labels map[string]string `json:"labels"` - } `json:"metadata"` - Status struct { - Conditions []struct { - Type string `json:"type"` - Status string `json:"status"` - } `json:"conditions"` - NodeInfo struct { - KubeletVersion string `json:"kubeletVersion"` - OperatingSystem string `json:"operatingSystem"` - Architecture string `json:"architecture"` - } `json:"nodeInfo"` - Addresses []struct { - Type string `json:"type"` - Address string `json:"address"` - } `json:"addresses"` - } `json:"status"` - } `json:"items"` - } - json.Unmarshal(nodesOut, &nodesData) - - for _, item := range nodesData.Items { + for _, item := range nodes.Items { status := "Unknown" for _, cond := range item.Status.Conditions { if cond.Type == "Ready" { @@ -107,58 +56,43 @@ func GetClusterInfo(ctx context.Context, cluster *models.Cluster) (*ClusterInfo, break } } - var roles []string - for label := range item.Metadata.Labels { + roles := []string{} + for label := range item.Labels { if strings.HasPrefix(label, "node-role.kubernetes.io/") { - roles = append(roles, label[len("node-role.kubernetes.io/"):]) + roles = append(roles, strings.TrimPrefix(label, "node-role.kubernetes.io/")) } } if len(roles) == 0 { roles = []string{"worker"} } - internalIP := "" - for _, addr := range item.Status.Addresses { - if addr.Type == "InternalIP" { - internalIP = addr.Address + sort.Strings(roles) + ip := "" + for _, address := range item.Status.Addresses { + if address.Type == "InternalIP" { + ip = address.Address break } } - info.Nodes = append(info.Nodes, NodeInfo{ - Name: item.Metadata.Name, - Status: status, - Roles: roles, - Version: item.Status.NodeInfo.KubeletVersion, - OS: item.Status.NodeInfo.OperatingSystem, - Arch: item.Status.NodeInfo.Architecture, - InternalIP: internalIP, - Labels: item.Metadata.Labels, - }) + info.Nodes = append(info.Nodes, NodeInfo{Name: item.Name, Status: status, Roles: roles, Version: item.Status.NodeInfo.KubeletVersion, OS: item.Status.NodeInfo.OperatingSystem, Arch: item.Status.NodeInfo.Architecture, InternalIP: ip, Labels: item.Labels}) } - - // Namespaces(best-effort) - if nsOut, err := runCmd(ctx, env, "kubectl", "get", "ns", "-o", "jsonpath={.items[*].metadata.name}"); err == nil { - info.Namespaces = strings.Fields(string(nsOut)) + if namespaces, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}); err == nil { + for _, item := range namespaces.Items { + info.Namespaces = append(info.Namespaces, item.Name) + } + sort.Strings(info.Namespaces) } - - // Contexts(best-effort) - if ctxOut, err := runCmd(ctx, env, "kubectl", "config", "get-contexts", "-o", "name"); err == nil { - for _, line := range strings.Split(strings.TrimSpace(string(ctxOut)), "\n") { - if line = strings.TrimSpace(line); line != "" { - info.Contexts = append(info.Contexts, line) + source, err := resolveKubeconfig(cluster) + if err == nil { + if raw, err := clientcmd.Load([]byte(source)); err == nil { + for name := range raw.Contexts { + info.Contexts = append(info.Contexts, name) + } + sort.Strings(info.Contexts) + info.CurrentCtx = raw.CurrentContext + if cluster.Context != "" { + info.CurrentCtx = cluster.Context } } } - - // Current context(best-effort) - if currentCtxOut, err := runCmd(ctx, env, "kubectl", "config", "current-context"); err == nil { - info.CurrentCtx = strings.TrimSpace(string(currentCtxOut)) - } - return info, nil } - -func runCmd(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Env = env - return cmd.CombinedOutput() -} diff --git a/backend/services/cluster_workloads.go b/backend/services/cluster_workloads.go new file mode 100644 index 0000000..63944f8 --- /dev/null +++ b/backend/services/cluster_workloads.go @@ -0,0 +1,63 @@ +package services + +import ( + "context" + "fmt" + "regexp" + "sort" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "opsxloki/models" +) + +type ClusterDeploymentOption struct { + Name string `json:"name"` + Replicas int `json:"replicas"` + ReadyReplicas int `json:"readyReplicas"` +} + +var kubernetesNamespacePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + +func ListClusterNamespaces(ctx context.Context, cluster *models.Cluster) ([]string, error) { + client, err := clusterClient(cluster, 10*time.Second) + if err != nil { + return nil, err + } + items, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("读取集群命名空间失败: %w", err) + } + result := make([]string, 0, len(items.Items)) + for _, item := range items.Items { + result = append(result, item.Name) + } + sort.Strings(result) + return result, nil +} + +func ListClusterDeployments(ctx context.Context, cluster *models.Cluster, namespace string) ([]ClusterDeploymentOption, error) { + if len(namespace) > 63 || !kubernetesNamespacePattern.MatchString(namespace) { + return nil, fmt.Errorf("命名空间名称无效") + } + client, err := clusterClient(cluster, 10*time.Second) + if err != nil { + return nil, err + } + items, err := client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("读取集群 Deployment 失败: %w", err) + } + result := make([]ClusterDeploymentOption, 0, len(items.Items)) + for _, item := range items.Items { + replicas := int32(1) + if item.Spec.Replicas != nil { + replicas = *item.Spec.Replicas + } + result = append(result, ClusterDeploymentOption{ + Name: item.Name, Replicas: int(replicas), ReadyReplicas: int(item.Status.ReadyReplicas), + }) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result, nil +} diff --git a/backend/services/cluster_workloads_test.go b/backend/services/cluster_workloads_test.go new file mode 100644 index 0000000..ba1744e --- /dev/null +++ b/backend/services/cluster_workloads_test.go @@ -0,0 +1,38 @@ +package services + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "opsxloki/models" + "testing" +) + +func TestClusterWorkloadSelectorsUseSelectedNamespace(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/namespaces": + fmt.Fprint(w, `{"apiVersion":"v1","kind":"NamespaceList","items":[{"metadata":{"name":"prod"}},{"metadata":{"name":"default"}}]}`) + case "/apis/apps/v1/namespaces/prod/deployments": + fmt.Fprint(w, `{"apiVersion":"apps/v1","kind":"DeploymentList","items":[{"metadata":{"name":"api"},"spec":{"replicas":3},"status":{"readyReplicas":2}}]}`) + default: + t.Errorf("unexpected Kubernetes API path %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + cluster := &models.Cluster{KubeConfig: fmt.Sprintf("apiVersion: v1\nkind: Config\nclusters:\n- name: test\n cluster:\n server: %s\ncontexts:\n- name: test\n context:\n cluster: test\n user: test\ncurrent-context: test\nusers:\n- name: test\n user: {}\n", server.URL)} + namespaces, err := ListClusterNamespaces(context.Background(), cluster) + if err != nil || len(namespaces) != 2 || namespaces[0] != "default" || namespaces[1] != "prod" { + t.Fatalf("unexpected namespaces: %v, %v", namespaces, err) + } + deployments, err := ListClusterDeployments(context.Background(), cluster, "prod") + if err != nil || len(deployments) != 1 || deployments[0].Name != "api" || deployments[0].Replicas != 3 || deployments[0].ReadyReplicas != 2 { + t.Fatalf("unexpected deployments: %v, %v", deployments, err) + } + if _, err := ListClusterDeployments(context.Background(), cluster, "-bad"); err == nil { + t.Fatal("expected invalid namespace to be rejected") + } +} diff --git a/backend/services/collector.go b/backend/services/collector.go index 2c4631e..90e78a4 100644 --- a/backend/services/collector.go +++ b/backend/services/collector.go @@ -201,6 +201,9 @@ func (s *CollectorService) Update(id uint, updates map[string]interface{}) error db.Collectors[i].StatusMessage = v } if v, ok := updates["logPath"].(string); ok { + if v != db.Collectors[i].LogPath { + db.Collectors[i].StatusMessage = "采集路径已保存,点击部署以应用新配置" + } db.Collectors[i].LogPath = v } if v, ok := updates["labels"].(string); ok { diff --git a/backend/services/collector_deployer.go b/backend/services/collector_deployer.go index 5930603..3d191a9 100644 --- a/backend/services/collector_deployer.go +++ b/backend/services/collector_deployer.go @@ -2,10 +2,11 @@ package services import ( "context" + "encoding/json" "fmt" - "os" - "os/exec" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "strings" + "time" "opsxloki/models" ) @@ -27,28 +28,6 @@ func (d *CollectorDeployer) log(level, message, detail string) { } } -// kubeconfigEnv 构造 kubectl/helm 所需的 KUBECONFIG 环境变量,返回 cleanup 删除临时文件 -func kubeconfigEnv(cluster *models.Cluster) (env []string, cleanup func(), err error) { - kubeconfig, err := resolveKubeconfig(cluster) - if err != nil { - return nil, nil, err - } - kubeFile, err := os.CreateTemp("", "kube-*.yaml") - if err != nil { - return nil, nil, fmt.Errorf("create kubeconfig temp file failed: %w", err) - } - if _, err := kubeFile.WriteString(kubeconfig); err != nil { - kubeFile.Close() - os.Remove(kubeFile.Name()) - return nil, nil, fmt.Errorf("write kubeconfig temp file failed: %w", err) - } - kubeFile.Close() - os.Chmod(kubeFile.Name(), 0600) - env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name())) - cleanup = func() { os.Remove(kubeFile.Name()) } - return env, cleanup, nil -} - // Install 按采集器类型分发安装 func (d *CollectorDeployer) Install(ctx context.Context) error { endpoint := d.loki.Endpoint @@ -206,11 +185,6 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin if cluster == nil { return fmt.Errorf("集群采集器缺少目标集群") } - env, cleanup, err := kubeconfigEnv(cluster) - if err != nil { - return err - } - defer cleanup() // 默认部署到关联 Loki 的命名空间(统一 log 命名空间策略) ns := d.collector.Namespace @@ -226,102 +200,43 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin clusterName = cluster.Name } - // 幂等:先清掉同名残留(如历史失败安装留下的无注解资源) - del := exec.CommandContext(ctx, "kubectl", "delete", - "daemonset/promtail", "configmap/promtail", "serviceaccount/promtail", - "-n", ns, "--ignore-not-found=true", "--wait=false") - del.Env = env - del.CombinedOutput() - - // 纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),不依赖后端安装 helm + // 删除历史残留并通过 Kubernetes API 提交资源。 + for _, kind := range []string{"daemonset", "configmap", "serviceaccount"} { + _ = deleteK8sResource(ctx, cluster, kind, ns, "promtail") + } manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels) - if err := d.applyYAML(ctx, env, manifest, "promtail"); err != nil { + if err := d.applyYAML(ctx, nil, manifest, "promtail"); err != nil { return err } - d.log("info", fmt.Sprintf("等待 DaemonSet promtail 就绪(命名空间 %s)...", ns), "") - waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "daemonset/promtail", - "-n", ns, "--timeout=5m") - waitCmd.Env = env - waitOut, _ := waitCmd.CombinedOutput() - d.log("info", string(waitOut), "") - - // DNS 自愈:CoreDNS 异常的集群上服务名不可解析,回退 Loki ClusterIP 重建配置 - svcURL := endpoint - if strings.Contains(endpoint, ".svc") { - probe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns, - "daemonset/promtail", "--", "wget", "-q", "-O", "/dev/null", "-T", "5", endpoint+"/ready") - probe.Env = env - if pout, perr := probe.CombinedOutput(); perr != nil { - d.log("warn", fmt.Sprintf("promtail 无法解析 Loki 服务名(%s),回退 ClusterIP...", strings.TrimSpace(string(pout))), "") - ip := d.lokiClusterIP(ctx, env, endpoint) - if ip != "" { - // 只替换 host 部分,保留 scheme/port/path: http://loki.ns.svc:3100/... → http://IP:3100/... - afterScheme := strings.SplitN(endpoint, "//", 2) - if len(afterScheme) == 2 { - rest := afterScheme[1] - host := rest - if i := strings.IndexAny(rest, ":/"); i >= 0 { - host = rest[:i] - } - fallback := afterScheme[0] + "//" + strings.Replace(rest, host, ip, 1) - if err := d.applyYAML(ctx, env, PromtailDaemonsetYAML(ns, fallback, clusterName, d.collector.Labels), "promtail"); err == nil { - rs := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "daemonset/promtail", "-n", ns) - rs.Env = env - rs.CombinedOutput() - d.log("info", fmt.Sprintf("promtail 推送地址已回退为 ClusterIP: %s", fallback), "") - svcURL = fallback - } - } - } - } + if err := waitDaemonSet(ctx, cluster, ns, "promtail"); err != nil { + return fmt.Errorf("Promtail DaemonSet 未就绪: %w", err) } + svcURL := endpoint d.collector.Config = svcURL d.log("success", fmt.Sprintf("Promtail DaemonSet 已部署到 %s(推送至 %s)", ns, svcURL), "") return nil } -// applyYAML kubectl apply 一个 manifest(临时文件) -func (d *CollectorDeployer) applyYAML(ctx context.Context, env []string, manifest, label string) error { - f, cleanup, err := writeTempFile("collector-*.yaml", manifest, 0600) - if err != nil { +// applyYAML 通过 API Server 应用采集器清单。 +func (d *CollectorDeployer) applyYAML(ctx context.Context, _ []string, manifest, label string) error { + if d.collector.Cluster == nil { + return fmt.Errorf("采集器缺少集群") + } + if err := applyManifest(ctx, d.collector.Cluster, manifest); err != nil { + d.log("error", fmt.Sprintf("应用 %s 失败: %v", label, err), "") return err } - defer cleanup() - cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", f) - cmd.Env = env - out, err := cmd.CombinedOutput() - if err != nil { - d.log("error", fmt.Sprintf("kubectl apply %s failed: %v", label, err), string(out)) - return fmt.Errorf("kubectl apply %s failed: %w\n%s", label, err, string(out)) - } return nil } -// lokiClusterIP 从 endpoint 的命名空间取 loki Service 的 ClusterIP(不经 DNS) -func (d *CollectorDeployer) lokiClusterIP(ctx context.Context, env []string, endpoint string) string { - ns := "log" - if d.loki != nil && d.loki.Namespace != "" { - ns = d.loki.Namespace - } - cmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns, - "-o", "jsonpath={.spec.clusterIP}") - cmd.Env = env - out, _ := cmd.CombinedOutput() - return strings.TrimSpace(string(out)) -} - func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error { cluster := d.collector.Cluster if cluster == nil { return nil } - env, cleanup, err := kubeconfigEnv(cluster) - if err != nil { - return err - } - defer cleanup() + ns := d.collector.Namespace if ns == "" && d.loki != nil { ns = d.loki.Namespace @@ -329,12 +244,11 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error { if ns == "" { ns = "log" } - cmd := exec.CommandContext(ctx, "kubectl", "delete", - "daemonset/promtail", "configmap/promtail", "serviceaccount/promtail", - "clusterrole/opsxloki-promtail", "clusterrolebinding/opsxloki-promtail", - "-n", ns, "--ignore-not-found=true") - cmd.Env = env - cmd.CombinedOutput() + for _, item := range [][2]string{{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"}, {"clusterrole", "opsxloki-promtail"}, {"clusterrolebinding", "opsxloki-promtail"}} { + if err := deleteK8sResource(ctx, cluster, item[0], ns, item[1]); err != nil { + return err + } + } return nil } @@ -349,11 +263,10 @@ func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) if target == "" { return fmt.Errorf("Sidecar 采集器缺少目标 Deployment") } - env, cleanup, err := kubeconfigEnv(cluster) + client, err := clusterClient(cluster, 30*time.Second) if err != nil { return err } - defer cleanup() ns := d.collector.Namespace if ns == "" { @@ -367,57 +280,65 @@ func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) // 1. 检查目标 deployment 是否存在 d.log("info", fmt.Sprintf("检查目标 Deployment %s/%s", ns, target), "") - check := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns) - check.Env = env - if out, err := check.CombinedOutput(); err != nil { - return fmt.Errorf("目标 Deployment 不存在: %s/%s\n%s", ns, target, string(out)) + deployment, err := client.AppsV1().Deployments(ns).Get(ctx, target, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("读取目标 Deployment %s/%s: %w", ns, target, err) } - - // 2. 幂等检查:是否已有 fluent-bit 容器 - getNames := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns, "-o", "jsonpath={.spec.template.spec.containers[*].name}") - getNames.Env = env - namesOut, _ := getNames.CombinedOutput() - if strings.Contains(string(namesOut), "fluent-bit") { - d.log("warn", "目标 Deployment 已含 fluent-bit 容器,跳过注入", "") - return nil + deploymentJSON, err := json.Marshal(deployment) + if err != nil { + return err + } + mount, err := resolveSidecarLogMount(deploymentJSON, target, logPath, d.collector.ID) + if err != nil { + return err + } + hasSidecar := false + for _, container := range deployment.Spec.Template.Spec.Containers { + if container.Name == "fluent-bit" { + hasSidecar = true + break + } } // 3. 部署 ConfigMap lokiHost, lokiPort := parseLokiURL(endpoint) conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns) - cm := FluentbitConfigMapYAML(conf, ns) - cmFile, cmCleanup, err := writeTempFile("fluent-bit-cm-*.yaml", cm, 0600) - if err != nil { - return err - } - defer cmCleanup() - + configMapName := fmt.Sprintf("fluent-bit-config-%d", d.collector.ID) + cm := FluentbitConfigMapYAML(conf, ns, configMapName) d.log("info", "部署 Fluent Bit ConfigMap", "") - applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile) - applyCm.Env = env - if out, err := applyCm.CombinedOutput(); err != nil { - return fmt.Errorf("ConfigMap 部署失败: %w\n%s", err, string(out)) + if err := applyManifest(ctx, cluster, cm); err != nil { + return fmt.Errorf("ConfigMap 部署失败: %w", err) } - - // 4. patch 注入 sidecar - d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "") - patchFile, patchCleanup, err := writeTempFile("sidecar-patch-*.yaml", SidecarPatchYAML, 0600) - if err != nil { - return err + d.log("info", fmt.Sprintf("为 %s 配置 Fluent Bit Sidecar", target), "") + if err := patchDeployment(ctx, cluster, ns, target, SidecarPatchYAML(configMapName, mount)); err != nil { + return fmt.Errorf("Sidecar 注入失败: %w", err) } - defer patchCleanup() - - patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile) - patch.Env = env - if out, err := patch.CombinedOutput(); err != nil { - return fmt.Errorf("Sidecar 注入失败: %w\n%s", err, string(out)) + if hasSidecar { + if err := restartDeployment(ctx, cluster, ns, target); err != nil { + return fmt.Errorf("Sidecar 配置已更新,但重启 Deployment 失败: %w", err) + } } - - // 5. 等待 rollout d.log("info", "等待 Pod 重建", "") - rollout := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/"+target, "-n", ns, "--timeout=5m") - rollout.Env = env - rollout.CombinedOutput() + if err := waitDeployment(ctx, cluster, ns, target); err != nil { + return fmt.Errorf("Sidecar Deployment 未就绪: %w", err) + } + + // DNS 自愈:CoreDNS 异常集群上 svc 域名不可解析 → 用 Loki ClusterIP 重建 ConfigMap 并滚动重启 + if strings.Contains(endpoint, ".svc") { + host := strings.Split(strings.Split(endpoint, "//")[1], ":")[0] + if podDNSBroken(ctx, cluster, ns, target, "fluent-bit", host) { + d.log("warn", "fluent-bit 无法解析 Loki 服务名,回退 ClusterIP…", "") + if ip, ierr := serviceClusterIP(ctx, cluster, lokiNamespaceOf(d.loki, endpoint), "loki"); ierr == nil && ip != "" { + conf2 := FluentbitConfig(ip, lokiPortOf(endpoint), logPath, target, ns) + cm2 := FluentbitConfigMapYAML(conf2, ns, configMapName) + if err := applyManifest(ctx, cluster, cm2); err == nil { + _ = restartDeployment(ctx, cluster, ns, target) + d.log("info", "fluent-bit 推送地址已回退为 ClusterIP: "+ip, "") + } + } + } + } + return nil } @@ -426,23 +347,52 @@ func (d *CollectorDeployer) uninstallSidecar(ctx context.Context) error { if cluster == nil || d.collector.TargetDeployment == "" { return nil } - env, cleanup, err := kubeconfigEnv(cluster) + client, err := clusterClient(cluster, 30*time.Second) if err != nil { return err } - defer cleanup() + ns := d.collector.Namespace if ns == "" { ns = "default" } - patchFile, patchCleanup, err := writeTempFile("sidecar-rm-*.yaml", SidecarRemovePatchYAML, 0600) + deployment, err := client.AppsV1().Deployments(ns).Get(ctx, d.collector.TargetDeployment, metav1.GetOptions{}) if err != nil { return err } - defer patchCleanup() + deploymentJSON, err := json.Marshal(deployment) + if err != nil { + return err + } + removePatch, err := sidecarRemovePatchYAML(deploymentJSON, d.collector.ID) + if err != nil { + return err + } + if err := patchDeployment(ctx, cluster, ns, d.collector.TargetDeployment, removePatch); err != nil { + return fmt.Errorf("移除 Sidecar 失败: %w", err) + } + if err := deleteK8sResource(ctx, cluster, "configmap", ns, fmt.Sprintf("fluent-bit-config-%d", d.collector.ID)); err != nil { + return err + } - patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile) - patch.Env = env - patch.CombinedOutput() return nil } + +// lokiPortOf 从 endpoint 提取端口(默认 3100) +func lokiPortOf(endpoint string) string { + rest := strings.SplitN(endpoint, "//", 2) + if len(rest) != 2 { + return "3100" + } + hostPart := rest[1] + if i := strings.Index(hostPart, ":"); i >= 0 { + port := hostPart[i+1:] + if j := strings.IndexAny(port, "/"); j >= 0 { + port = port[:j] + } + if port != "" { + return port + } + } + return "3100" +} diff --git a/backend/services/collector_sidecar_mount.go b/backend/services/collector_sidecar_mount.go new file mode 100644 index 0000000..a0fe7e3 --- /dev/null +++ b/backend/services/collector_sidecar_mount.go @@ -0,0 +1,150 @@ +package services + +import ( + "encoding/json" + "fmt" + "path" + "strings" +) + +type sidecarLogMount struct { + containerName string + volumeName string + mountPath string + subPath string + createVolume bool + mountApplication bool +} + +type sidecarDeployment struct { + Spec struct { + Template struct { + Spec struct { + Containers []struct { + Name string `json:"name"` + VolumeMounts []struct { + Name string `json:"name"` + MountPath string `json:"mountPath"` + SubPath string `json:"subPath"` + SubPathExpr string `json:"subPathExpr"` + ReadOnly bool `json:"readOnly"` + } `json:"volumeMounts"` + } `json:"containers"` + Volumes []struct { + Name string `json:"name"` + } `json:"volumes"` + } `json:"spec"` + } `json:"template"` + } `json:"spec"` +} + +// resolveSidecarLogMount 优先复用业务容器已有的卷;否则仅在专用日志目录创建共享 emptyDir。 +func resolveSidecarLogMount(deploymentJSON []byte, target, logPath string, collectorID uint) (sidecarLogMount, error) { + var deployment sidecarDeployment + if err := json.Unmarshal(deploymentJSON, &deployment); err != nil { + return sidecarLogMount{}, fmt.Errorf("读取目标 Deployment 失败: %w", err) + } + logDir := path.Dir(strings.TrimSpace(logPath)) + if !path.IsAbs(logPath) || logDir == "/" || strings.ContainsAny(logDir, "*?[]\n\r") { + return sidecarLogMount{}, fmt.Errorf("Sidecar 采集路径需为专用目录下的绝对文件路径或 glob,例如 /var/log/app/*.log") + } + + containers := deployment.Spec.Template.Spec.Containers + var appIndex = -1 + var candidate = -1 + for i, container := range containers { + if container.Name == target { + appIndex = i + break + } + if container.Name != "fluent-bit" { + if candidate >= 0 { + candidate = -2 + } else if candidate == -1 { + candidate = i + } + } + } + if appIndex < 0 { + appIndex = candidate + } + if appIndex < 0 { + return sidecarLogMount{}, fmt.Errorf("目标 Deployment 中无法确定业务容器,请使容器名与 Deployment 名一致,或仅保留一个业务容器") + } + app := containers[appIndex] + mount := sidecarLogMount{containerName: app.Name} + longest := -1 + for _, item := range app.VolumeMounts { + prefix := strings.TrimSuffix(item.MountPath, "/") + if (logDir == prefix || strings.HasPrefix(logDir, prefix+"/")) && len(prefix) > longest { + if item.ReadOnly || item.SubPathExpr != "" { + return sidecarLogMount{}, fmt.Errorf("业务容器的日志卷必须可写,且不能使用 subPathExpr") + } + mount.volumeName = item.Name + mount.mountPath = item.MountPath + mount.subPath = item.SubPath + longest = len(prefix) + } + } + if mount.volumeName != "" { + if mount.mountPath == "/tmp" { + return sidecarLogMount{}, fmt.Errorf("日志卷不能挂载在 /tmp:Fluent Bit 用此目录保存读取位置;请改用专用日志目录") + } + return mount, nil + } + + // 自动挂载会遮蔽目录原有内容,仅对明确的专用日志目录执行。 + if logDir != "/var/log/app" && path.Base(logDir) != "logs" && path.Base(logDir) != "log" { + return sidecarLogMount{}, fmt.Errorf("业务容器未共享 %s;请将采集路径改为 /var/log/app/*.log 或已有日志卷中的文件", logDir) + } + volumeName := fmt.Sprintf("loki-collector-logs-%d", collectorID) + for _, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.Name == volumeName { + return sidecarLogMount{}, fmt.Errorf("卷 %s 已存在但业务容器未挂载,请先检查目标 Deployment", volumeName) + } + } + mount.volumeName = volumeName + mount.mountPath = logDir + mount.createVolume = true + mount.mountApplication = true + return mount, nil +} + +// sidecarRemovePatchYAML 只清理该采集策略注入的卷,保留业务原有的日志卷。 +func sidecarRemovePatchYAML(deploymentJSON []byte, collectorID uint) (string, error) { + var deployment sidecarDeployment + if err := json.Unmarshal(deploymentJSON, &deployment); err != nil { + return "", fmt.Errorf("读取目标 Deployment 失败: %w", err) + } + injectedName := fmt.Sprintf("loki-collector-logs-%d", collectorID) + appPatch := "" + for _, container := range deployment.Spec.Template.Spec.Containers { + if container.Name == "fluent-bit" { + continue + } + for _, mount := range container.VolumeMounts { + if mount.Name == injectedName { + appPatch += fmt.Sprintf(" - name: %s\n volumeMounts:\n - mountPath: %q\n $patch: delete\n", container.Name, mount.MountPath) + } + } + } + volumePatch := "" + for _, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.Name == injectedName { + volumePatch = fmt.Sprintf(" - name: %s\n $patch: delete\n", injectedName) + break + } + } + return fmt.Sprintf(`spec: + template: + spec: + containers: + - name: fluent-bit + $patch: delete +%s volumes: + - name: fluent-bit-config + $patch: delete + - name: fluent-bit-state + $patch: delete +%s`, appPatch, volumePatch), nil +} diff --git a/backend/services/collector_sidecar_mount_test.go b/backend/services/collector_sidecar_mount_test.go new file mode 100644 index 0000000..cd15eb3 --- /dev/null +++ b/backend/services/collector_sidecar_mount_test.go @@ -0,0 +1,64 @@ +package services + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestSidecarReusesApplicationLogVolume(t *testing.T) { + deployment := []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"st-bos-forex-order","volumeMounts":[{"name":"app-logs","mountPath":"/bitcloud/app/st-bos-forex-order/logs"}]},{"name":"fluent-bit"}],"volumes":[{"name":"app-logs"}]}}}}`) + mount, err := resolveSidecarLogMount(deployment, "st-bos-forex-order", "/bitcloud/app/st-bos-forex-order/logs/*.log", 42) + if err != nil { + t.Fatal(err) + } + if mount.volumeName != "app-logs" || mount.mountPath != "/bitcloud/app/st-bos-forex-order/logs" || mount.createVolume || mount.mountApplication { + t.Fatalf("should reuse the existing application mount: %+v", mount) + } + patch := SidecarPatchYAML("fluent-bit-config-42", mount) + var parsed map[string]any + if err := yaml.Unmarshal([]byte(patch), &parsed); err != nil { + t.Fatalf("invalid strategic merge patch: %v", err) + } + if !strings.Contains(patch, "name: fluent-bit-config-42") || !strings.Contains(patch, "name: app-logs") { + t.Fatalf("patch does not reference the existing log volume and dedicated config: %s", patch) + } +} + +func TestSidecarCreatesSharedVolumeOnlyForDedicatedDirectory(t *testing.T) { + deployment := []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"jar-monitor"}]}}}}`) + mount, err := resolveSidecarLogMount(deployment, "jar-monitor", "/var/log/app/*.log", 7) + if err != nil { + t.Fatal(err) + } + if !mount.createVolume || !mount.mountApplication || mount.mountPath != "/var/log/app" || mount.volumeName != "loki-collector-logs-7" { + t.Fatalf("expected a shared volume for both containers: %+v", mount) + } + patch := SidecarPatchYAML("fluent-bit-config-7", mount) + var parsed map[string]any + if err := yaml.Unmarshal([]byte(patch), &parsed); err != nil { + t.Fatalf("invalid strategic merge patch: %v", err) + } + if !strings.Contains(patch, "name: jar-monitor\n volumeMounts:") { + t.Fatalf("application container is missing its log mount: %s", patch) + } + if _, err := resolveSidecarLogMount(deployment, "jar-monitor", "/tmp/test.log", 7); err == nil { + t.Fatal("/tmp must not be covered by a new emptyDir") + } +} + +func TestSidecarRemovalKeepsApplicationOwnedVolume(t *testing.T) { + deployment := []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"app","volumeMounts":[{"name":"app-logs","mountPath":"/business/logs"},{"name":"loki-collector-logs-7","mountPath":"/var/log/app"}]},{"name":"fluent-bit"}],"volumes":[{"name":"app-logs"},{"name":"loki-collector-logs-7"},{"name":"fluent-bit-config"}]}}}}`) + patch, err := sidecarRemovePatchYAML(deployment, 7) + if err != nil { + t.Fatal(err) + } + var parsed map[string]any + if err := yaml.Unmarshal([]byte(patch), &parsed); err != nil { + t.Fatalf("invalid removal patch: %v", err) + } + if strings.Contains(patch, "name: app-logs") || !strings.Contains(patch, "name: loki-collector-logs-7") || !strings.Contains(patch, `mountPath: "/var/log/app"`) { + t.Fatalf("removal must only clean collector-owned mounts: %s", patch) + } +} diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index 46b73b9..fb6f513 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -194,6 +194,7 @@ extraVolumeMounts: // 取代 helm 安装 —— 后端无需安装 helm 二进制。 // relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。 func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string) string { + promtailImage := ResolveImage("docker.io/grafana/promtail:3.4.0") extLabels := promtailExternalLabels(clusterName, labelsJSON) return fmt.Sprintf(`apiVersion: v1 kind: ServiceAccount @@ -292,7 +293,7 @@ spec: operator: Exists containers: - name: promtail - image: grafana/promtail:3.4.0 + image: %s imagePullPolicy: IfNotPresent args: ["-config.file=/etc/promtail/promtail.yaml"] env: @@ -340,11 +341,11 @@ spec: - name: containers hostPath: path: /var/lib/docker/containers -`, ns, ns, ns, lokiURL, extLabels, ns) +`, ns, ns, ns, lokiURL, extLabels, ns, promtailImage) } // FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort) -// 生产级配置:kubernetes过滤器、内存缓冲、健康检查 +// Sidecar 已明确知道应用与命名空间,无需 Kubernetes 元数据过滤器。 func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string { return fmt.Sprintf(`[SERVICE] Flush 1 @@ -352,11 +353,9 @@ func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string HTTP_Server On HTTP_Listen 0.0.0.0 HTTP_Port 2020 - Parsers_File parsers.conf storage.path /tmp/flb-storage/ storage.sync normal storage.checksum off - storage.max_chunks_pause On storage.backlog.mem_limit 5M Health_Check On HC_Errors_Count 5 @@ -383,70 +382,69 @@ func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string Add app %s Add source fluent-bit -[FILTER] - Name kubernetes - Match app.* - Kube_URL https://kubernetes.default.svc:443 - Kube_CA_File /var/run/secrets/kubernetes.io/serviceaccount/ca.crt - Kube_Token_File /var/run/secrets/kubernetes.io/serviceaccount/token - Merge_Log On - Keep_Log Off - K8S-Logging.Parser On - K8S-Logging.Exclude On - Labels On - Annotations Off - [OUTPUT] Name loki Match * Host %s Port %s Labels job=fluent-bit, app=%s, namespace=%s - Drop_Records_Without_Labels On Line_Format json - Auto_Kubernetes_Labels On Retry_Limit 10 `, logPath, app, namespace, app, lokiHost, lokiPort, app, namespace) } -// FluentbitConfigMapYAML 包装 fluent-bit.conf 为 ConfigMap YAML(部署到 namespace) -func FluentbitConfigMapYAML(conf, namespace string) string { +// FluentbitConfigMapYAML 为每个采集策略生成独立 ConfigMap,避免同命名空间的 Sidecar 相互覆盖。 +func FluentbitConfigMapYAML(conf, namespace, name string) string { return fmt.Sprintf(`apiVersion: v1 kind: ConfigMap metadata: - name: fluent-bit-config + name: %s namespace: %s data: fluent-bit.conf: | %s -`, namespace, indentBlock(conf, " ")) +`, name, namespace, indentBlock(conf, " ")) } -// SidecarPatchYAML strategic merge patch:为 Deployment 注入 Fluent Bit sidecar 容器 -// 生产级配置:资源限制、健康检查、安全上下文 -const SidecarPatchYAML = `spec: +// SidecarPatchYAML 将业务容器与 Fluent Bit 挂到同一日志卷和目录。 +func SidecarPatchYAML(configMapName string, mount sidecarLogMount) string { + volume := "" + if mount.createVolume { + volume = fmt.Sprintf(" - name: %s\n emptyDir: {}\n", mount.volumeName) + } + appMount := "" + if mount.mountApplication { + appMount = fmt.Sprintf(" - name: %s\n volumeMounts:\n - name: %s\n mountPath: %q\n", mount.containerName, mount.volumeName, mount.mountPath) + } + subPath := "" + if mount.subPath != "" { + subPath = fmt.Sprintf(" subPath: %q\n", mount.subPath) + } + return fmt.Sprintf(`spec: template: spec: volumes: - - name: app-logs - emptyDir: {} - - name: fluent-bit-config +%s - name: fluent-bit-config configMap: - name: fluent-bit-config + name: %s + - name: fluent-bit-state + emptyDir: {} containers: - - name: fluent-bit - image: cr.fluentbit.io/fluent/fluent-bit:3.1.1 +%s - name: fluent-bit + image: %s imagePullPolicy: IfNotPresent ports: - - name: http + - name: fluent-bit-http containerPort: 2020 protocol: TCP volumeMounts: - - name: app-logs - mountPath: /var/log/app - readOnly: true + - name: %s + mountPath: %q +%s readOnly: true - name: fluent-bit-config mountPath: /fluent-bit/etc/ + - name: fluent-bit-state + mountPath: /tmp resources: limits: cpu: 200m @@ -463,30 +461,17 @@ const SidecarPatchYAML = `spec: livenessProbe: httpGet: path: / - port: http + port: fluent-bit-http initialDelaySeconds: 10 periodSeconds: 10 readinessProbe: httpGet: path: / - port: http + port: fluent-bit-http initialDelaySeconds: 5 periodSeconds: 5 -` - -// SidecarRemovePatchYAML strategic merge patch:从 Deployment 移除 Fluent Bit sidecar(卸载) -const SidecarRemovePatchYAML = `spec: - template: - spec: - containers: - - name: fluent-bit - $patch: delete - volumes: - - name: app-logs - $patch: delete - - name: fluent-bit-config - $patch: delete -` +`, volume, configMapName, appMount, ResolveImage("docker.io/fluent/fluent-bit:5.1.5"), mount.volumeName, mount.mountPath, subPath) +} // indentBlock 给每行加前缀(ConfigMap 嵌入配置需缩进) func indentBlock(s, pad string) string { diff --git a/backend/services/deployment.go b/backend/services/deployment.go index 341cdf7..5882ed0 100644 --- a/backend/services/deployment.go +++ b/backend/services/deployment.go @@ -1,9 +1,9 @@ package services import ( - "os/exec" "context" "fmt" + "net" "strconv" "strings" "sync" @@ -11,6 +11,7 @@ import ( "opsxloki/models" "opsxloki/repository" + "opsxloki/seed" "opsxloki/utils" ) @@ -29,6 +30,8 @@ var LokiVersionList = []string{ // minSupportedMajorVersion 最低支持的主版本号 const minSupportedMajorVersion = 2 +const DefaultRetentionDays = 30 + // DeploymentService 部署业务逻辑层 type DeploymentService struct{} @@ -43,6 +46,8 @@ type CreateDeploymentRequest struct { Mode string `json:"mode" binding:"required"` ClusterID *uint `json:"clusterID"` HostID *uint `json:"hostID"` + ListenIP string `json:"listenIP"` + ListenPort int `json:"listenPort"` Namespace string `json:"namespace"` StorageConfigID uint `json:"storageConfigID"` AuthEnabled bool `json:"authEnabled"` @@ -56,6 +61,41 @@ type CreateDeploymentRequest struct { } func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) { + if req.Mode != "k8s-yaml" && req.Mode != "host-docker" && req.Mode != "host-binary" { + return nil, fmt.Errorf("不支持的部署方式: %s", req.Mode) + } + if req.GrafanaEnabled { + return nil, fmt.Errorf("Grafana 自动部署已移除,请使用独立 Grafana 服务") + } + if req.Mode == "host-docker" { + if _, err := dockerLokiImage(req.Version); err != nil { + return nil, err + } + if req.HostID == nil || *req.HostID == 0 { + return nil, fmt.Errorf("请选择目标主机") + } + if req.ListenPort == 0 { + req.ListenPort = 3100 + } + if req.ListenPort < 1 || req.ListenPort > 65535 { + return nil, fmt.Errorf("监听端口须在 1 到 65535 之间") + } + if req.Replicas > 1 { + return nil, fmt.Errorf("主机 Docker 部署仅支持单副本") + } + if req.GrafanaEnabled { + return nil, fmt.Errorf("主机 Docker 模式暂不支持同时部署 Grafana") + } + if req.ListenIP != "" && net.ParseIP(req.ListenIP).To4() == nil { + return nil, fmt.Errorf("监听地址须为 IPv4 地址") + } + } + if req.RetentionDays < 0 { + return nil, fmt.Errorf("保留天数必须是正整数") + } + if req.RetentionDays == 0 { + req.RetentionDays = DefaultRetentionDays + } // 版本兼容性预检查 if req.Version != "" { if err := ValidateLokiVersion(req.Version); err != nil { @@ -72,6 +112,8 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe Mode: req.Mode, ClusterID: req.ClusterID, HostID: req.HostID, + ListenIP: req.ListenIP, + ListenPort: req.ListenPort, Namespace: req.Namespace, StorageConfigID: req.StorageConfigID, AuthEnabled: req.AuthEnabled, @@ -107,6 +149,17 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe } } } + if d.Mode == "host-docker" { + if d.Host == nil { + return nil, fmt.Errorf("目标主机不存在") + } + if d.ListenIP == "" { + d.ListenIP = d.Host.HostIP + } + if net.ParseIP(d.ListenIP).To4() == nil { + return nil, fmt.Errorf("主机 IP 格式无效,请填写监听内网 IPv4 地址") + } + } if d.StorageConfigID > 0 { for i := range db.Storages { if db.Storages[i].ID == d.StorageConfigID { @@ -169,6 +222,12 @@ func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, i } func (s *DeploymentService) Update(id uint, updates map[string]interface{}) error { + if value, exists := updates["retentionDays"]; exists { + days, ok := value.(float64) + if !ok || days < 1 || days != float64(int(days)) { + return fmt.Errorf("保留天数必须是正整数") + } + } db := repository.GetDB() db.Lock() defer db.Unlock() @@ -243,6 +302,22 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro } func (s *DeploymentService) Delete(id uint) error { + deployment, err := s.Get(id) + if err != nil { + return err + } + if deployment.Mode == "host-docker" { + if deployment.Status == "deploying" { + return fmt.Errorf("部署正在进行中,请等待完成后删除") + } + if deployment.Status != "pending" { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + if err := deleteDockerLoki(ctx, deployment); err != nil { + return err + } + } + } db := repository.GetDB() db.Lock() defer db.Unlock() @@ -323,10 +398,11 @@ func (s *DeploymentService) StartDeploy(id uint) error { "statusMessage": "Deployment completed successfully", "endpoint": s.getEndpoint(deployment), }) - // 部署成功自动注册日志查询数据源 + 记录 Grafana 访问地址 + // 部署成功自动注册日志查询数据源 + 记录 Grafana 访问地址 + 补内置告警模板 if fresh, gerr := s.Get(id); gerr == nil { EnsureLogSourceForDeployment(fresh) go s.recordGrafanaEndpoint(fresh) + go seed.SeedAlertRules() } s.AddLog(id, "success", "Deployment completed successfully", "") } @@ -353,7 +429,13 @@ func (s *DeploymentService) StopDeployment(id uint) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - CleanupLoki(ctx, deployment) + if deployment.Mode == "host-docker" { + if err := stopDockerLoki(ctx, deployment); err != nil { + return err + } + } else { + CleanupLoki(ctx, deployment) + } return s.Update(id, map[string]interface{}{ "status": "stopped", "statusMessage": "Deployment stopped by user", @@ -499,6 +581,8 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string { if d.Host != nil { return fmt.Sprintf("http://%s:3100", d.Host.HostIP) } + case "host-docker": + return fmt.Sprintf("http://%s:%d", d.ListenIP, d.ListenPort) } return "" } @@ -599,12 +683,12 @@ func (s *DeploymentService) Preflight() map[string]interface{} { d := &db.Deployments[i] if d.Status == "running" { loki := map[string]interface{}{ - "id": d.ID, - "name": d.Name, - "mode": d.Mode, - "endpoint": d.Endpoint, - "namespace": d.Namespace, - "grafana": d.GrafanaEnabled, + "id": d.ID, + "name": d.Name, + "mode": d.Mode, + "endpoint": d.Endpoint, + "namespace": d.Namespace, + "grafana": d.GrafanaEnabled, "grafanaEndpoint": d.GrafanaEndpoint, } result["runningLoki"] = loki @@ -638,29 +722,16 @@ func (s *DeploymentService) recordGrafanaEndpoint(d *models.LokiDeployment) { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - env, cleanup, err := kubeconfigEnv(d.Cluster) - if err != nil { - return - } - defer cleanup() ns := d.Namespace if ns == "" { ns = "log" } - ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes", - "-o", "jsonpath={.items[0].status.addresses[0].address}") - ipCmd.Env = env - ipOut, _ := ipCmd.CombinedOutput() - portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns, - "-o", "jsonpath={.spec.ports[0].nodePort}") - portCmd.Env = env - portOut, _ := portCmd.CombinedOutput() - ip := strings.TrimSpace(string(ipOut)) - port := strings.TrimSpace(string(portOut)) - s.AddLog(d.ID, "info", fmt.Sprintf("Grafana 地址探测: node=%s port=%s", ip, port), "") - if ip != "" && port != "" { - endpoint = fmt.Sprintf("http://%s:%s", ip, port) + ip, ipErr := firstNodeIP(ctx, d.Cluster) + port, portErr := serviceNodePort(ctx, d.Cluster, ns, "grafana") + if ipErr == nil && portErr == nil && port > 0 { + endpoint = fmt.Sprintf("http://%s:%d", ip, port) } + case "host-binary": if d.Host != nil { endpoint = fmt.Sprintf("http://%s:3000", d.Host.HostIP) diff --git a/backend/services/deployment_config.go b/backend/services/deployment_config.go new file mode 100644 index 0000000..81985dc --- /dev/null +++ b/backend/services/deployment_config.go @@ -0,0 +1,120 @@ +package services + +import ( + "context" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "time" +) + +type LiveLokiConfig struct { + Location string `json:"location"` + YAML string `json:"yaml"` +} + +// GetLiveConfig 读取实际运行的 Loki 配置。仅返回 loki.yaml,不返回整个 ConfigMap 元数据。 +func (s *DeploymentService) GetLiveConfig(ctx context.Context, id uint) (*LiveLokiConfig, error) { + d, err := s.Get(id) + if err != nil { + return nil, err + } + var raw, location string + switch d.Mode { + case "k8s-yaml": + if d.Cluster == nil { + return nil, fmt.Errorf("部署未关联 Kubernetes 集群") + } + client, err := clusterClient(d.Cluster, 30*time.Second) + if err != nil { + return nil, err + } + ns := d.Namespace + if ns == "" { + ns = "log" + } + configMap, err := client.CoreV1().ConfigMaps(ns).Get(ctx, "loki-config", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("无法读取 %s/loki-config: %w", ns, err) + } + raw = configMap.Data["loki.yaml"] + location = fmt.Sprintf("ConfigMap %s/loki-config → loki.yaml", ns) + case "host-binary": + if d.Host == nil { + return nil, fmt.Errorf("部署未关联主机") + } + client, err := NewSSHClientWithAuth(HostSSHAuth(d.Host)) + if err != nil { + return nil, fmt.Errorf("连接部署主机失败") + } + defer client.Close() + output, _, err := client.Execute(ctx, "cat /etc/loki/loki.yaml") + if err != nil { + return nil, fmt.Errorf("无法读取主机上的 /etc/loki/loki.yaml") + } + raw = output + location = "/etc/loki/loki.yaml" + case "host-docker": + if d.Host == nil { + return nil, fmt.Errorf("部署未关联主机") + } + client, err := NewSSHClientWithAuth(HostSSHAuth(d.Host)) + if err != nil { + return nil, fmt.Errorf("连接部署主机失败") + } + defer client.Close() + location = dockerConfigPath(d.ID) + output, _, err := client.Execute(ctx, "cat "+location) + if err != nil { + return nil, fmt.Errorf("无法读取主机上的 Loki 配置") + } + raw = output + default: + return nil, fmt.Errorf("当前部署模式暂不支持查看运行配置") + } + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("运行配置为空或尚未部署") + } + redacted, err := redactLokiYAML(raw) + if err != nil { + return nil, fmt.Errorf("运行配置无法安全展示:YAML 格式无效") + } + return &LiveLokiConfig{Location: location, YAML: redacted}, nil +} + +func redactLokiYAML(source string) (string, error) { + var document yaml.Node + if err := yaml.Unmarshal([]byte(source), &document); err != nil { + return "", err + } + redactYAMLNode(&document) + output, err := yaml.Marshal(&document) + return string(output), err +} + +func redactYAMLNode(node *yaml.Node) { + if node.Kind == yaml.ScalarNode && strings.HasPrefix(node.Value, "ENC:") { + node.Value = "********" + return + } + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + key, value := node.Content[i], node.Content[i+1] + switch strings.ToLower(key.Value) { + case "access_key_id", "secret_access_key", "account_key", "password", "token", "client_secret": + value.Kind = yaml.ScalarNode + value.Tag = "!!str" + value.Value = "********" + value.Content = nil + continue + } + redactYAMLNode(value) + } + return + } + for _, child := range node.Content { + redactYAMLNode(child) + } +} diff --git a/backend/services/deployment_config_test.go b/backend/services/deployment_config_test.go new file mode 100644 index 0000000..7fe4bc8 --- /dev/null +++ b/backend/services/deployment_config_test.go @@ -0,0 +1,138 @@ +package services + +import ( + "context" + "encoding/base64" + "io" + "regexp" + "strings" + "testing" + + "gopkg.in/yaml.v3" + "opsxloki/models" +) + +func TestRedactLokiYAML(t *testing.T) { + source := "storage_config:\n aws:\n bucketnames: loki\n access_key_id: ENC:private-access\n secret_access_key: private-secret\nlimits_config:\n retention_period: 720h\n" + got, err := redactLokiYAML(source) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"private-access", "private-secret", "ENC:"} { + if strings.Contains(got, secret) { + t.Fatalf("sensitive value remains in config: %s", secret) + } + } + for _, want := range []string{"bucketnames: loki", "retention_period: 720h", "access_key_id: '********'"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in sanitized config: %s", want, got) + } + } +} + +func TestMinIOConfigUsesBucketNameNotPathPrefix(t *testing.T) { + d := NewLokiDeployer(&models.LokiDeployment{StorageConfig: &models.StorageConfig{ + StorageType: "minio", Bucket: "logs-bucket", PathPrefix: "wrong-prefix", Endpoint: "http://minio:9000", + AccessKey: "key", SecretKey: "secret", Insecure: true, + }}, nil) + config := d.generateLokiConfig() + if count := strings.Count(config, "bucketnames: logs-bucket"); count != 2 { + t.Fatalf("bucket name should appear in storage and ruler config; got %d", count) + } + if strings.Contains(config, "wrong-prefix") || strings.Contains(config, "s3://") { + t.Fatalf("invalid S3 URL or path prefix in generated config") + } +} + +func TestDeployRejectsUndecryptedStorageCredential(t *testing.T) { + d := NewLokiDeployer(&models.LokiDeployment{StorageConfig: &models.StorageConfig{ + StorageType: "minio", AccessKey: "ENC:unavailable", SecretKey: "secret", + }}, nil) + if err := d.Deploy(context.Background()); err == nil || !strings.Contains(err.Error(), "存储凭证解密失败") { + t.Fatalf("expected credential failure, got %v", err) + } +} + +func TestGeneratedLokiConfigKeepsRequiredSettingsWithoutDefaults(t *testing.T) { + d := NewLokiDeployer(&models.LokiDeployment{RetentionDays: 90, Replicas: 1, StorageConfig: &models.StorageConfig{ + StorageType: "minio", Bucket: "logs", Endpoint: "http://minio:9000", + AccessKey: "key", SecretKey: "secret", Insecure: true, + }}, nil) + config := d.generateLokiConfig() + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(config), &parsed); err != nil { + t.Fatalf("invalid Loki YAML: %v", err) + } + for _, required := range []string{"retention_period: 2160h", "retention_enabled: true", "delete_request_store: s3", "bucketnames: logs", "store: tsdb", "schema: v13"} { + if !strings.Contains(config, required) { + t.Fatalf("required setting %q missing", required) + } + } + for _, defaultSetting := range []string{"http_listen_port:", "grpc_listen_port:", "chunk_idle_period:", "compaction_interval:", "retention_delete_delay:", "max_entries_limit_per_query:", "frontend_worker:", "chunk_store_config:", "autoforget_unhealthy:"} { + if strings.Contains(config, defaultSetting) { + t.Fatalf("unnecessary setting %q remained", defaultSetting) + } + } + d.deployment.Replicas = 2 + if !strings.Contains(d.generateLokiConfig(), "autoforget_unhealthy: true") { + t.Fatal("multi-replica ring recovery setting missing") + } +} + +func TestK8sManifestKeepsStorageCredentialsInSecret(t *testing.T) { + d := NewLokiDeployer(&models.LokiDeployment{Mode: "k8s-yaml", RetentionDays: 60, Replicas: 1, StorageConfig: &models.StorageConfig{ + StorageType: "minio", Bucket: "loki", Endpoint: "http://minio:9000", + AccessKey: "test-access", SecretKey: "test-secret", Insecure: true, + }}, nil) + manifest := d.generateManifest("log") + decoder := yaml.NewDecoder(strings.NewReader(manifest)) + var configYAML string + var secret map[string]interface{} + for { + var doc map[string]interface{} + if err := decoder.Decode(&doc); err != nil { + if err == io.EOF { + break + } + t.Fatalf("invalid manifest YAML: %v", err) + } + switch doc["kind"] { + case "ConfigMap": + configYAML = doc["data"].(map[string]interface{})["loki.yaml"].(string) + case "Secret": + secret = doc["data"].(map[string]interface{}) + } + } + if configYAML == "" || secret == nil { + t.Fatal("ConfigMap or Secret missing") + } + if strings.Contains(configYAML, "test-access") || strings.Contains(configYAML, "test-secret") { + t.Fatal("storage credential leaked into ConfigMap") + } + for _, marker := range []string{"${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}", "retention_period: 1440h", "retention_enabled: true"} { + if !strings.Contains(configYAML, marker) { + t.Fatalf("required Loki setting %q missing", marker) + } + } + if !strings.Contains(manifest, "-config.expand-env=true") || !strings.Contains(manifest, "secretKeyRef:") { + t.Fatal("StatefulSet does not reference storage Secret") + } + for key, want := range map[string]string{"accessKey": "test-access", "secretKey": "test-secret"} { + decoded, err := base64.StdEncoding.DecodeString(secret[key].(string)) + if err != nil || string(decoded) != want { + t.Fatalf("Secret %s data mismatch", key) + } + } +} + +func TestK8sManifestChangesPodTemplateWhenRetentionChanges(t *testing.T) { + deployment := &models.LokiDeployment{RetentionDays: 30, Replicas: 1} + d := NewLokiDeployer(deployment, nil) + checksum := regexp.MustCompile(`checksum/loki-config: "[a-f0-9]{64}"`) + first := checksum.FindString(d.generateManifest("log")) + deployment.RetentionDays = 70 + second := checksum.FindString(d.generateManifest("log")) + if first == "" || second == "" || first == second { + t.Fatalf("Pod config checksum must change with retention: %q -> %q", first, second) + } +} diff --git a/backend/services/deployment_retention.go b/backend/services/deployment_retention.go new file mode 100644 index 0000000..701bf89 --- /dev/null +++ b/backend/services/deployment_retention.go @@ -0,0 +1,79 @@ +package services + +import ( + "fmt" + "io" + "regexp" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +var retentionPeriodPattern = regexp.MustCompile(`^([1-9][0-9]*)(d|h)$`) + +// parseRetentionYAML 仅接收 limits_config.retention_period,避免把任意配置或凭证写入部署。 +func parseRetentionYAML(source string) (int, error) { + var fragment struct { + LimitsConfig *struct { + RetentionPeriod string `yaml:"retention_period"` + } `yaml:"limits_config"` + } + decoder := yaml.NewDecoder(strings.NewReader(source)) + decoder.KnownFields(true) + if err := decoder.Decode(&fragment); err != nil { + return 0, fmt.Errorf("YAML 格式无效: %w", err) + } + var extra interface{} + if err := decoder.Decode(&extra); err != io.EOF { + return 0, fmt.Errorf("只允许一段 limits_config YAML") + } + if fragment.LimitsConfig == nil { + return 0, fmt.Errorf("缺少 limits_config.retention_period") + } + match := retentionPeriodPattern.FindStringSubmatch(fragment.LimitsConfig.RetentionPeriod) + if match == nil { + return 0, fmt.Errorf("retention_period 须使用正整数天数,如 90d;也支持 2160h") + } + value, err := strconv.Atoi(match[1]) + if err != nil { + return 0, fmt.Errorf("保留时长超出范围") + } + if match[2] == "h" { + if value%24 != 0 { + return 0, fmt.Errorf("小时数必须是 24 的倍数") + } + value /= 24 + } + if value < 1 || value > 36500 { + return 0, fmt.Errorf("保留天数须在 1 到 36500 之间") + } + return value, nil +} + +// UpdateRetention 保存 Loki 的保留时长;apply 为 true 时触发当前部署重新生成并应用配置。 +func (s *DeploymentService) UpdateRetention(id uint, source string, apply bool) (int, error) { + days, err := parseRetentionYAML(source) + if err != nil { + return 0, err + } + deployment, err := s.Get(id) + if err != nil { + return 0, err + } + if deployment.Status == "deploying" { + return 0, fmt.Errorf("部署正在进行中,请稍后编辑") + } + if apply && deployment.Status != "running" { + return 0, fmt.Errorf("当前部署未运行,请仅保存后再部署") + } + if err := s.Update(id, map[string]interface{}{"retentionDays": float64(days)}); err != nil { + return 0, err + } + if apply { + if err := s.StartDeploy(id); err != nil { + return days, fmt.Errorf("保留配置已保存,但重新部署失败: %w", err) + } + } + return days, nil +} diff --git a/backend/services/deployment_retention_test.go b/backend/services/deployment_retention_test.go new file mode 100644 index 0000000..9cd8dc9 --- /dev/null +++ b/backend/services/deployment_retention_test.go @@ -0,0 +1,68 @@ +package services + +import ( + "testing" + + "opsxloki/repository" +) + +func TestParseRetentionYAML(t *testing.T) { + for _, tc := range []struct { + name string + source string + want int + }{ + {"days", "limits_config:\n retention_period: 90d\n", 90}, + {"hours", "limits_config:\n retention_period: 2160h\n", 90}, + {"unknown field", "limits_config:\n retention_period: 90d\n ingestion_rate_mb: 16\n", 0}, + {"fractional day", "limits_config:\n retention_period: 25h\n", 0}, + {"extra document", "limits_config:\n retention_period: 90d\n---\nfoo: bar\n", 0}, + {"missing field", "limits_config: {}\n", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := parseRetentionYAML(tc.source) + if tc.want == 0 && err == nil { + t.Fatalf("accepted invalid YAML: %q", tc.source) + } + if tc.want != 0 && (err != nil || got != tc.want) { + t.Fatalf("got %d, err %v; want %d", got, err, tc.want) + } + }) + } +} + +func TestDeploymentRetentionPersistsAndRejectsInvalidValues(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters") + t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters") + if err := repository.InitDB(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = repository.Shutdown() }) + + service := NewDeploymentService() + deployment, err := service.Create(&CreateDeploymentRequest{ + Name: "loki", Mode: "k8s-yaml", Version: "3.0.0", + }) + if err != nil { + t.Fatal(err) + } + if deployment.RetentionDays != DefaultRetentionDays { + t.Fatalf("default retention = %d, want %d", deployment.RetentionDays, DefaultRetentionDays) + } + for _, invalid := range []float64{0, -1, 1.5} { + if err := service.Update(deployment.ID, map[string]interface{}{"retentionDays": invalid}); err == nil { + t.Fatalf("accepted invalid retention %v", invalid) + } + } + if err := service.Update(deployment.ID, map[string]interface{}{"retentionDays": float64(90)}); err != nil { + t.Fatal(err) + } + updated, err := service.Get(deployment.ID) + if err != nil || updated.RetentionDays != 90 { + t.Fatalf("updated retention = %v, err = %v", updated, err) + } + if err := repository.GetDB().RequestSaveSync(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/services/grafana_deployer.go b/backend/services/grafana_deployer.go deleted file mode 100644 index a93ed8a..0000000 --- a/backend/services/grafana_deployer.go +++ /dev/null @@ -1,438 +0,0 @@ -package services - -import ( - "context" - "encoding/base64" - "fmt" - "io" - "net/http" - "os/exec" - "strings" - "time" - - "opsxloki/models" -) - -// GrafanaDeployer 与 Loki 同环境部署 Grafana 并自动配置 Loki 数据源。 -// k8s 模式:manifest(Deployment + NodePort Service + 数据源 ConfigMap),与 Loki 同 namespace; -// host-binary 模式:tarball + systemd,与 Loki 同机。 -type GrafanaDeployer struct { - deployment *models.LokiDeployment - log func(level, message, detail string) -} - -const defaultGrafanaVersion = "11.3.0" - -func (d *GrafanaDeployer) version() string { - if d.deployment.GrafanaVersion != "" { - return d.deployment.GrafanaVersion - } - return defaultGrafanaVersion -} - -// lokiPushURL 数据源指向的 Loki 地址(k8s 走集群内服务名,主机走本机) -func (d *GrafanaDeployer) lokiPushURL() string { - if d.deployment.Mode == "host-binary" { - return "http://127.0.0.1:3100" - } - ns := d.deployment.Namespace - if ns == "" { - ns = "log" - } - return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns) -} - -// Deploy 部署入口:按模式分发 -func (d *GrafanaDeployer) Deploy(ctx context.Context) error { - d.log("info", fmt.Sprintf("Deploying Grafana %s (mode: %s)...", d.version(), d.deployment.Mode), "") - switch d.deployment.Mode { - case "k8s-helm", "k8s-yaml": - return d.deployK8s(ctx) - case "host-binary": - return d.deployHost(ctx) - default: - return fmt.Errorf("unsupported mode for grafana: %s", d.deployment.Mode) - } -} - -// ==================== K8s manifest 部署 ==================== - -// grafanaManifest 生成 Grafana 资源;lokiURL 显式传入(默认服务名,DNS 异常的集群可回退 ClusterIP)。 -// 数据卷用 emptyDir:数据源/配置由 ConfigMap 供给,重启即恢复;避免无默认 -// StorageClass 的集群上 PVC Pending 阻塞(UI 内自建的面板需重新导入,见已知限制)。 -func (d *GrafanaDeployer) grafanaManifest(ns, lokiURL string) string { - version := d.version() - - datasources := fmt.Sprintf(`apiVersion: 1 -datasources: -- name: Loki - type: loki - uid: loki-ds - access: proxy - url: %s - isDefault: true - editable: false`, lokiURL) - - return fmt.Sprintf(`apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-datasources - namespace: %s -data: - datasources.yaml: | -%s ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: grafana - namespace: %s - labels: - app: grafana -spec: - replicas: 1 - selector: - matchLabels: - app: grafana - template: - metadata: - labels: - app: grafana - spec: - containers: - - name: grafana - image: grafana/grafana:%s - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 3000 - env: - - name: GF_SECURITY_ADMIN_USER - value: admin - - name: GF_SECURITY_ADMIN_PASSWORD - value: admin - readinessProbe: - httpGet: - path: /api/health - port: http - initialDelaySeconds: 10 - periodSeconds: 10 - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi - volumeMounts: - - name: datasources - mountPath: /etc/grafana/provisioning/datasources - volumes: - - name: datasources - configMap: - name: grafana-datasources ---- -apiVersion: v1 -kind: Service -metadata: - name: grafana - namespace: %s - labels: - app: grafana -spec: - # NodePort 便于集群外直接访问(端口由集群自动分配,实际端口见部署日志) - type: NodePort - ports: - - name: http - port: 3000 - targetPort: 3000 - selector: - app: grafana -`, ns, indentBlock(datasources, " "), ns, version, ns) -} - -func (d *GrafanaDeployer) deployK8s(ctx context.Context) error { - if d.deployment.Cluster == nil { - return fmt.Errorf("please select target cluster") - } - env, cleanup, err := kubeconfigEnv(d.deployment.Cluster) - if err != nil { - return err - } - defer cleanup() - - ns := d.deployment.Namespace - if ns == "" { - ns = "log" - } - - lokiURL := d.lokiPushURL() - manifestFile, manifestCleanup, err := writeTempFile("grafana-*.yaml", d.grafanaManifest(ns, lokiURL), 0600) - if err != nil { - return err - } - defer manifestCleanup() - - d.log("info", "Applying Grafana manifest (Deployment + NodePort Service + datasource ConfigMap)...", "") - cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile) - cmd.Env = env - out, err := cmd.CombinedOutput() - if err != nil { - d.log("error", fmt.Sprintf("kubectl apply grafana failed: %v", err), string(out)) - return fmt.Errorf("kubectl apply grafana failed: %w\n%s", err, string(out)) - } - - waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana", - "-n", ns, "--timeout=5m") - waitCmd.Env = env - waitOut, _ := waitCmd.CombinedOutput() - d.log("info", string(waitOut), "") - - // DNS 自愈:部分集群 CoreDNS 对 Service 记录异常(如 watch 失效), - // 经 grafana 容器探测 Loki 服务名,不通则回退 ClusterIP 并热更新数据源。 - dnsProbe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns, "deploy/grafana", "--", - "wget", "-q", "-O", "/dev/null", "-T", "5", lokiURL+"/ready") - dnsProbe.Env = env - if probeOut, probeErr := dnsProbe.CombinedOutput(); probeErr != nil { - d.log("warn", fmt.Sprintf("Grafana 无法解析 Loki 服务名(%s),回退 ClusterIP…", strings.TrimSpace(string(probeOut))), "") - ipCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns, - "-o", "jsonpath={.spec.clusterIP}") - ipCmd.Env = env - ipOut, ipErr := ipCmd.CombinedOutput() - clusterIP := strings.TrimSpace(string(ipOut)) - if ipErr != nil || clusterIP == "" { - d.log("error", "获取 Loki ClusterIP 失败,数据源保持服务名形式", "") - } else { - fallbackURL := fmt.Sprintf("http://%s:3100", clusterIP) - fbFile, fbCleanup, ferr := writeTempFile("grafana-fb-*.yaml", d.grafanaManifest(ns, fallbackURL), 0600) - if ferr == nil { - fbApply := exec.CommandContext(ctx, "kubectl", "apply", "-f", fbFile) - fbApply.Env = env - fbApply.CombinedOutput() - fbCleanup() - restart := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "deployment/grafana", "-n", ns) - restart.Env = env - restart.CombinedOutput() - wait2 := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana", - "-n", ns, "--timeout=5m") - wait2.Env = env - w2, _ := wait2.CombinedOutput() - d.log("info", string(w2), "") - d.log("info", fmt.Sprintf("数据源已回退为 ClusterIP: %s", fallbackURL), "") - } - } - } - - // 回读实际分配的 NodePort(固定端口易与集群已有服务冲突) - portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns, - "-o", "jsonpath={.spec.ports[0].nodePort}") - portCmd.Env = env - portOut, _ := portCmd.CombinedOutput() - nodePort := strings.TrimSpace(string(portOut)) - access := fmt.Sprintf("http://<节点IP>:%s", nodePort) - if nodePort == "" { - access = "kubectl get svc grafana -n " + ns + " 查看访问端口" - } - - d.log("success", fmt.Sprintf("Grafana deployed. 访问: %s (NodePort, 账号 admin/admin), 数据源已指向 %s", access, d.lokiPushURL()), "") - - // 自动导入 Loki 日志面板(打开即可看图;密码被用户修改过则跳过并提示) - if err := d.importDashboard(ctx, env, ns); err != nil { - d.log("warn", fmt.Sprintf("面板自动导入跳过: %v", err), "") - } else { - d.log("info", "已导入「Loki 日志」面板(Explore 可用,默认账号 admin/admin)", "") - } - return nil -} - -// importDashboard 通过 Grafana API 导入预置的 Loki 日志面板 -func (d *GrafanaDeployer) importDashboard(ctx context.Context, env []string, ns string) error { - payload := grafanaDashboardPayload() - - postDashboard := func(baseURL string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - strings.TrimSuffix(baseURL, "/")+"/api/dashboards/db", strings.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:admin"))) - resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 300)) - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) - } - return nil - } - - switch d.deployment.Mode { - case "k8s-helm", "k8s-yaml": - // 经 NodePort 直连(kube service proxy 的 POST 需额外 RBAC,直连最稳) - ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes", - "-o", "jsonpath={.items[0].status.addresses[0].address}") - ipCmd.Env = env - ipOut, _ := ipCmd.CombinedOutput() - portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns, - "-o", "jsonpath={.spec.ports[0].nodePort}") - portCmd.Env = env - portOut, _ := portCmd.CombinedOutput() - ip, port := strings.TrimSpace(string(ipOut)), strings.TrimSpace(string(portOut)) - if ip == "" || port == "" { - return fmt.Errorf("获取 NodePort 失败") - } - return postDashboard(fmt.Sprintf("http://%s:%s", ip, port)) - case "host-binary": - if d.deployment.Host == nil { - return fmt.Errorf("no host") - } - return postDashboard(fmt.Sprintf("http://%s:3000", d.deployment.Host.HostIP)) - } - return fmt.Errorf("unsupported mode") -} - -// ==================== 主机 tarball 部署 ==================== - -func (d *GrafanaDeployer) deployHost(ctx context.Context) error { - host := d.deployment.Host - if host == nil { - return fmt.Errorf("please select target host") - } - client, err := NewSSHClientWithAuth(HostSSHAuth(host)) - if err != nil { - return err - } - defer client.Close() - - arch, _, _ := client.Execute(ctx, "uname -m") - grafanaArch := "amd64" - if strings.Contains(arch, "aarch64") || strings.Contains(arch, "arm64") { - grafanaArch = "arm64" - } - version := d.version() - - d.log("info", fmt.Sprintf("Downloading Grafana %s (%s)...", version, grafanaArch), "") - url := fmt.Sprintf("https://dl.grafana.com/oss/release/grafana-%s.linux-%s.tar.gz", version, grafanaArch) - dl := fmt.Sprintf("curl -fsSL -o /tmp/grafana.tar.gz %s && tar -xzf /tmp/grafana.tar.gz -C /opt/ && rm -rf /opt/grafana && ln -sfn /opt/grafana-v%s /opt/grafana && rm -f /tmp/grafana.tar.gz", url, version) - if out, _, err := client.Execute(ctx, dl); err != nil { - return fmt.Errorf("download grafana failed: %w\n%s", err, out) - } - - // 数据源 provisioning(指向同机 Loki) - dsYAML := fmt.Sprintf(`apiVersion: 1 -datasources: -- name: Loki - type: loki - access: proxy - url: %s - isDefault: true -`, d.lokiPushURL()) - client.Execute(ctx, "mkdir -p /etc/grafana/provisioning/datasources /var/lib/grafana /var/log/grafana") - writeCmd := fmt.Sprintf("cat > /etc/grafana/provisioning/datasources/loki.yaml <<'EOF'\n%s\nEOF", dsYAML) - if out, _, err := client.Execute(ctx, writeCmd); err != nil { - return fmt.Errorf("write datasource config failed: %w\n%s", err, out) - } - - unit := `[Unit] -Description=Grafana -After=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=/opt/grafana -ExecStart=/opt/grafana/bin/grafana server --homepath /opt/grafana --config /opt/grafana/conf/defaults.ini -Restart=always -LimitNOFILE=65536 - -[Install] -WantedBy=multi-user.target` - writeUnit := fmt.Sprintf("cat > /etc/systemd/system/grafana.service <<'EOF'\n%s\nEOF", unit) - if out, _, err := client.Execute(ctx, writeUnit); err != nil { - return fmt.Errorf("write systemd unit failed: %w\n%s", err, out) - } - if out, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable grafana && systemctl restart grafana"); err != nil { - return fmt.Errorf("start grafana failed: %w\n%s", err, out) - } - - // 健康检查(最多 10 次) - for i := 0; i < 10; i++ { - out, _, _ := client.Execute(ctx, "curl -fsSL http://localhost:3000/api/health") - if strings.Contains(out, "\"version\"") { - d.log("success", fmt.Sprintf("Grafana running on %s:3000 (admin/admin), 数据源已指向本机 Loki", host.HostIP), "") - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - } - } - d.log("warn", "Grafana 服务已启动但健康检查未通过,请稍后确认 http://<主机IP>:3000", "") - return nil -} - -// CleanupGrafana 卸载 Grafana(停止部署时同步调用) -func CleanupGrafana(ctx context.Context, deployment *models.LokiDeployment) { - if deployment == nil { - return - } - switch deployment.Mode { - case "k8s-helm", "k8s-yaml": - if deployment.Cluster == nil { - return - } - env, cleanup, err := kubeconfigEnv(deployment.Cluster) - if err != nil { - return - } - defer cleanup() - ns := deployment.Namespace - if ns == "" { - ns = "log" - } - cmd := exec.CommandContext(ctx, "kubectl", "delete", - "deployment/grafana", "service/grafana", "configmap/grafana-datasources", - "-n", ns, "--ignore-not-found=true") - cmd.Env = env - cmd.CombinedOutput() - case "host-binary": - if deployment.Host == nil { - return - } - client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host)) - if err != nil { - return - } - defer client.Close() - client.Execute(ctx, "systemctl stop grafana 2>/dev/null; systemctl disable grafana 2>/dev/null; rm -f /etc/systemd/system/grafana.service; systemctl daemon-reload") - } -} - -// grafanaDashboardPayload 预置「Loki 日志」面板:日志量(按命名空间)/错误日志/最近日志 -func grafanaDashboardPayload() string { - return `{"dashboard":{ - "uid":"loki-logs","title":"Loki 日志","tags":["lokideploy"], - "timezone":"browser","schemaVersion":39,"refresh":"30s","version":1, - "time":{"from":"now-1h","to":"now"}, - "panels":[ - {"id":1,"type":"timeseries","title":"日志量(按命名空间)", - "datasource":{"type":"loki","uid":"loki-ds"}, - "gridPos":{"h":8,"w":12,"x":0,"y":0}, - "fieldConfig":{"defaults":{"unit":"short"},"overrides":[]}, - "targets":[{"refId":"A","expr":"sum by (namespace) (count_over_time({namespace=\".+\"}[5m]))","queryType":"range"}]}, - {"id":2,"type":"timeseries","title":"错误日志", - "datasource":{"type":"loki","uid":"loki-ds"}, - "gridPos":{"h":8,"w":12,"x":12,"y":0}, - "fieldConfig":{"defaults":{"unit":"short","color":{"mode":"palette-classic"}},"overrides":[]}, - "targets":[{"refId":"A","expr":"sum(count_over_time({namespace=\".+\"} |~ \"(?i)error\" [5m]))","queryType":"range"}]}, - {"id":3,"type":"logs","title":"最近日志", - "datasource":{"type":"loki","uid":"loki-ds"}, - "gridPos":{"h":10,"w":24,"x":0,"y":8}, - "targets":[{"refId":"A","expr":"{namespace=\".+\"}","queryType":"range"}]} - ] - },"overwrite":true}` -} diff --git a/backend/services/host.go b/backend/services/host.go index 1a31a11..9e01b2f 100644 --- a/backend/services/host.go +++ b/backend/services/host.go @@ -120,6 +120,33 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error { if v, ok := updates["arch"].(string); ok { db.Hosts[i].Arch = v } + if v, ok := updates["hostIP"].(string); ok && v != "" { + db.Hosts[i].HostIP = v + } + if v, ok := updates["port"].(float64); ok && v > 0 { + db.Hosts[i].Port = int(v) + } + if v, ok := updates["user"].(string); ok && v != "" { + db.Hosts[i].User = v + } + if v, ok := updates["labels"].(string); ok { + db.Hosts[i].Labels = v + } + // 凭证编辑约定:非空才更新(留空=保留旧密文) + for _, field := range []string{"password", "privateKey", "keyPhrase"} { + if v, ok := updates[field].(string); ok && v != "" { + if enc, err := crypto.EncryptField(v); err == nil { + switch field { + case "password": + db.Hosts[i].Password = enc + case "privateKey": + db.Hosts[i].PrivateKey = enc + case "keyPhrase": + db.Hosts[i].KeyPhrase = enc + } + } + } + } db.Hosts[i].UpdatedAt = time.Now() db.RequestSave() return nil diff --git a/backend/services/image_registry.go b/backend/services/image_registry.go new file mode 100644 index 0000000..5492bc7 --- /dev/null +++ b/backend/services/image_registry.go @@ -0,0 +1,131 @@ +package services + +// 镜像仓库源 — 全局设置(参照 kuboard 的国内/海外镜像选择): +// overseas = docker.io 官方源;cn = daocloud 国内镜像;custom = 用户自填 registry 前缀。 +// 影响平台拉取的全部镜像:Loki / Grafana / Promtail / Fluent Bit。 +import ( + "encoding/json" + "fmt" + "strings" + + "opsxloki/models" + "opsxloki/repository" +) + +const ( + imageRegistrySettingKey = "image_registry" + RegistryOverseas = "overseas" // docker.io + RegistryCN = "cn" // docker.m.daocloud.io(国内加速) + RegistryCustom = "custom" // 用户私有仓库前缀 + daocloudPrefix = "docker.m.daocloud.io" +) + +// ImageRegistryConfig 镜像源配置 +type ImageRegistryConfig struct { + Mode string `json:"mode"` // overseas | cn | custom + CustomHost string `json:"customHost"` // custom 模式下的仓库前缀,如 swr.cn-east-2.myhuaweicloud.com/xxx +} + +type ImageRegistryPublic struct { + Mode string `json:"mode"` + CustomHost string `json:"customHost"` + // 示例预览(前端展示生效镜像) + SampleLoki string `json:"sampleLoki"` + SampleGrafana string `json:"sampleGrafana"` + SamplePromtail string `json:"samplePromtail"` + SampleFluentBit string `json:"sampleFluentBit"` +} + +func GetImageRegistry() *ImageRegistryConfig { + db := repository.GetDB() + if db == nil { + return &ImageRegistryConfig{Mode: RegistryOverseas} + } + db.RLock() + var raw string + for i := range db.Settings { + if db.Settings[i].Key == imageRegistrySettingKey { + raw = db.Settings[i].Value + break + } + } + db.RUnlock() + cfg := &ImageRegistryConfig{Mode: RegistryOverseas} + if raw != "" { + _ = json.Unmarshal([]byte(raw), cfg) + } + if cfg.Mode == "" { + cfg.Mode = RegistryOverseas + } + return cfg +} + +func SaveImageRegistry(mode, customHost string) error { + if mode != RegistryOverseas && mode != RegistryCN && mode != RegistryCustom { + return fmt.Errorf("无效的镜像源模式") + } + if mode == RegistryCustom { + customHost = strings.TrimSuffix(strings.TrimSpace(customHost), "/") + if customHost == "" || !strings.Contains(customHost, ".") { + return fmt.Errorf("自定义镜像仓库地址无效(需含域名,如 swr.cn-east-2.myhuaweicloud.com/your-ns)") + } + } + data, _ := json.Marshal(&ImageRegistryConfig{Mode: mode, CustomHost: customHost}) + db := repository.GetDB() + newID := db.NextID("settings") + db.Lock() + defer db.Unlock() + for i := range db.Settings { + if db.Settings[i].Key == imageRegistrySettingKey { + db.Settings[i].Value = string(data) + db.RequestSave() + return nil + } + } + db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: imageRegistrySettingKey, Value: string(data)}) + db.RequestSave() + return nil +} + +// ResolveImage 按当前镜像源解析镜像地址:docker.io/grafana/loki:3.4.0 形式的官方镜像 +// 会按 mode 重写前缀;已带私有仓库前缀(含镜像源域名)的地址原样返回。 +func ResolveImage(official string) string { + cfg := GetImageRegistry() + switch cfg.Mode { + case RegistryCN: + if strings.HasPrefix(official, "docker.io/") { + return daocloudPrefix + "/" + strings.TrimPrefix(official, "docker.io/") + } + if !strings.Contains(strings.SplitN(official, "/", 2)[0], ".") { + // 无仓库前缀的官方镜像(grafana/loki:tag) + return daocloudPrefix + "/" + official + } + return official + case RegistryCustom: + if strings.HasPrefix(official, "docker.io/") { + return cfg.CustomHost + "/" + strings.TrimPrefix(official, "docker.io/") + } + if !strings.Contains(strings.SplitN(official, "/", 2)[0], ".") { + return cfg.CustomHost + "/" + official + } + return official + default: + if strings.HasPrefix(official, "docker.io/") { + return official // 官方模式保留全限定名 + } + return official + } +} + +// ImageRegistryPublicConfig 对外输出(含示例预览) +func ImageRegistryPublicConfig() *ImageRegistryPublic { + cfg := GetImageRegistry() + return &ImageRegistryPublic{ + Mode: cfg.Mode, + CustomHost: cfg.CustomHost, + SampleLoki: ResolveImage("docker.io/grafana/loki:3.4.0"), + SampleGrafana: ResolveImage("docker.io/grafana/grafana:11.3.0"), + SamplePromtail: ResolveImage("docker.io/grafana/promtail:3.4.0"), + SampleFluentBit: ResolveImage("docker.io/fluent/fluent-bit:5.1.5"), + } +} diff --git a/backend/services/ingress_access.go b/backend/services/ingress_access.go new file mode 100644 index 0000000..03be3cf --- /dev/null +++ b/backend/services/ingress_access.go @@ -0,0 +1,184 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "opsxloki/models" +) + +const ingressNamespace = "ingress-nginx" +const ingressControllerName = "ingress-nginx-controller" + +// The field names are shared with the JSON parser on the Loki query page. +const ingressAccessFormat = `{"time":"$time_iso8601","remote_addr":"$remote_addr","host":"$host","http_host":"$http_host","request":"$request","method":"$request_method","uri":"$request_uri","status":$status,"body_bytes_sent":$body_bytes_sent,"http_referer":"$http_referer","http_user_agent":"$http_user_agent","request_length":$request_length,"request_time":$request_time,"proxy_upstream_name":"$proxy_upstream_name","upstream_addr":"$upstream_addr","upstream_status":"$upstream_status","upstream_response_time":"$upstream_response_time"}` + +type IngressAccessStatus struct { + Namespace string `json:"namespace"` + Deployment string `json:"deployment"` + Image string `json:"image"` + ConfigMap string `json:"configMap"` + ConfigMapAttached bool `json:"configMapAttached"` + CurrentFormat string `json:"currentFormat"` + TargetFormat string `json:"targetFormat"` + Standardized bool `json:"standardized"` + ManagedBy string `json:"managedBy"` +} + +func ingressConfigArg(args []string) string { + for i, arg := range args { + if strings.HasPrefix(arg, "--configmap=") { + return strings.TrimPrefix(arg, "--configmap=") + } + if arg == "--configmap" && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func ingressAccessState(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) { + client, err := clusterClient(cluster, 10*time.Second) + if err != nil { + return IngressAccessStatus{}, err + } + dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{}) + if err != nil { + return IngressAccessStatus{}, fmt.Errorf("读取 ingress-nginx 控制器失败: %w", err) + } + index := -1 + for i, container := range dep.Spec.Template.Spec.Containers { + if strings.Contains(container.Image, "ingress-nginx/controller:") { + index = i + break + } + } + if index < 0 { + return IngressAccessStatus{}, fmt.Errorf("未识别到社区 ingress-nginx controller 容器") + } + container := dep.Spec.Template.Spec.Containers[index] + configName := ingressConfigArg(container.Args) + if configName == "" { + configName = ingressNamespace + "/" + ingressControllerName + } + parts := strings.Split(configName, "/") + if len(parts) != 2 || parts[0] != ingressNamespace || parts[1] == "" { + return IngressAccessStatus{}, fmt.Errorf("控制器使用其他命名空间的 ConfigMap: %s", configName) + } + cm, err := client.CoreV1().ConfigMaps(parts[0]).Get(ctx, parts[1], metav1.GetOptions{}) + if err != nil { + return IngressAccessStatus{}, fmt.Errorf("读取 ingress-nginx ConfigMap 失败: %w", err) + } + format := cm.Data["log-format-upstream"] + return IngressAccessStatus{ + Namespace: ingressNamespace, Deployment: dep.Name, Image: container.Image, + ConfigMap: configName, ConfigMapAttached: ingressConfigArg(container.Args) != "", + CurrentFormat: format, TargetFormat: ingressAccessFormat, + Standardized: format == ingressAccessFormat && cm.Data["log-format-escape-json"] == "true" && ingressConfigArg(container.Args) != "", + ManagedBy: dep.Labels["app.kubernetes.io/managed-by"], + }, nil +} + +func InspectIngressAccess(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) { + return ingressAccessState(ctx, cluster) +} + +// ApplyIngressAccess changes only the two log keys, preserving unrelated ingress settings. +// Explicitly attaching the ConfigMap is necessary for controllers launched without --configmap. +func ApplyIngressAccess(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) { + state, err := ingressAccessState(ctx, cluster) + if err != nil { + return state, err + } + client, err := clusterClient(cluster, 10*time.Second) + if err != nil { + return state, err + } + parts := strings.Split(state.ConfigMap, "/") + cm, err := client.CoreV1().ConfigMaps(parts[0]).Get(ctx, parts[1], metav1.GetOptions{}) + if err != nil { + return state, err + } + if !state.ConfigMapAttached { + for key := range cm.Data { + if key != "log-format-upstream" && key != "log-format-escape-json" { + return state, fmt.Errorf("控制器尚未关联 ConfigMap,且其中已有 %s 等设置;为避免意外启用原有配置,已跳过自动配置", key) + } + } + } + previousFormat := strings.Replace(ingressAccessFormat, `"request":"$request",`, "", 1) + if current := cm.Data["log-format-upstream"]; current != "" && current != ingressAccessFormat && current != previousFormat { + return state, fmt.Errorf("已有自定义 access log 格式;为避免覆盖,已跳过自动配置") + } + if state.Standardized { + if err := ensureIngressRolloutStrategy(ctx, cluster); err != nil { + return state, err + } + return state, nil + } + if cm.Data == nil { + cm.Data = make(map[string]string) + } + cm.Data["log-format-upstream"] = ingressAccessFormat + cm.Data["log-format-escape-json"] = "true" + if _, err = client.CoreV1().ConfigMaps(parts[0]).Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + return state, fmt.Errorf("更新 ingress-nginx ConfigMap 失败: %w", err) + } + if !state.ConfigMapAttached { + dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{}) + if err != nil { + return state, err + } + for i := range dep.Spec.Template.Spec.Containers { + container := &dep.Spec.Template.Spec.Containers[i] + if strings.Contains(container.Image, "ingress-nginx/controller:") { + container.Args = append(container.Args, "--configmap="+state.ConfigMap) + break + } + } + setIngressRolloutStrategy(dep) + if _, err = client.AppsV1().Deployments(ingressNamespace).Update(ctx, dep, metav1.UpdateOptions{}); err != nil { + return state, fmt.Errorf("ConfigMap 已更新,但控制器尚未关联配置;更新 Deployment 失败: %w", err) + } + } + return ingressAccessState(ctx, cluster) +} + +// Host-network replicas occupying every ingress node cannot use a surge pod: +// the new pod would need the same 80/443 ports as an existing replica. +func setIngressRolloutStrategy(dep *appsv1.Deployment) bool { + if !dep.Spec.Template.Spec.HostNetwork || dep.Spec.Replicas == nil || *dep.Spec.Replicas < 2 { + return false + } + zero, one := intstr.FromInt32(0), intstr.FromInt32(1) + strategy := &dep.Spec.Strategy + if strategy.Type == appsv1.RecreateDeploymentStrategyType { + return false + } + if strategy.RollingUpdate != nil && strategy.RollingUpdate.MaxSurge != nil && *strategy.RollingUpdate.MaxSurge == zero && strategy.RollingUpdate.MaxUnavailable != nil && *strategy.RollingUpdate.MaxUnavailable == one { + return false + } + strategy.Type = appsv1.RollingUpdateDeploymentStrategyType + strategy.RollingUpdate = &appsv1.RollingUpdateDeployment{MaxSurge: &zero, MaxUnavailable: &one} + return true +} + +func ensureIngressRolloutStrategy(ctx context.Context, cluster *models.Cluster) error { + client, err := clusterClient(cluster, 10*time.Second) + if err != nil { + return err + } + dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{}) + if err != nil { + return err + } + if setIngressRolloutStrategy(dep) { + _, err = client.AppsV1().Deployments(ingressNamespace).Update(ctx, dep, metav1.UpdateOptions{}) + } + return err +} diff --git a/backend/services/ingress_access_test.go b/backend/services/ingress_access_test.go new file mode 100644 index 0000000..c608069 --- /dev/null +++ b/backend/services/ingress_access_test.go @@ -0,0 +1,37 @@ +package services + +import ( + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +func TestIngressAccessConfiguration(t *testing.T) { + if got := ingressConfigArg([]string{"/nginx-ingress-controller", "--configmap=ingress-nginx/ingress-nginx-controller"}); got != "ingress-nginx/ingress-nginx-controller" { + t.Fatalf("unexpected configmap: %q", got) + } + if got := ingressConfigArg([]string{"--configmap", "custom/controller"}); got != "custom/controller" { + t.Fatalf("unexpected separate configmap flag: %q", got) + } + for _, field := range []string{`"host":"$host"`, `"http_host":"$http_host"`, `"request":"$request"`, `"http_referer":"$http_referer"`, `"method":"$request_method"`, `"status":$status`} { + if !strings.Contains(ingressAccessFormat, field) { + t.Errorf("missing access log field %s", field) + } + } +} + +func TestIngressHostNetworkRolloutStrategy(t *testing.T) { + replicas := int32(2) + dep := &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: &replicas, Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{HostNetwork: true}}}} + if !setIngressRolloutStrategy(dep) { + t.Fatal("expected host-network rollout adjustment") + } + if dep.Spec.Strategy.RollingUpdate.MaxSurge.IntVal != 0 || dep.Spec.Strategy.RollingUpdate.MaxUnavailable.IntVal != 1 { + t.Fatalf("unexpected rollout strategy: %+v", dep.Spec.Strategy.RollingUpdate) + } + if setIngressRolloutStrategy(dep) { + t.Fatal("strategy update must be idempotent") + } +} diff --git a/backend/services/k8s_client.go b/backend/services/k8s_client.go index cd2b8c5..b1ffc7a 100644 --- a/backend/services/k8s_client.go +++ b/backend/services/k8s_client.go @@ -5,8 +5,12 @@ import ( "fmt" "os" "regexp" + "time" "opsxloki/models" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" ) // resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件) @@ -24,6 +28,37 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) { return "", fmt.Errorf("集群未配置 kubeconfig") } +// clusterRESTConfig 使用集群保存的 kubeconfig 构造原生 API Server 客户端配置。 +func clusterRESTConfig(cluster *models.Cluster, timeout time.Duration) (*rest.Config, error) { + source, err := resolveKubeconfig(cluster) + if err != nil { + return nil, err + } + config, err := clientcmd.Load([]byte(source)) + if err != nil { + return nil, fmt.Errorf("解析集群 kubeconfig 失败: %w", err) + } + overrides := &clientcmd.ConfigOverrides{} + if cluster.Context != "" { + overrides.CurrentContext = cluster.Context + } + result, err := clientcmd.NewNonInteractiveClientConfig(*config, config.CurrentContext, overrides, nil).ClientConfig() + if err != nil { + return nil, fmt.Errorf("构造 Kubernetes 客户端失败: %w", err) + } + result.Timeout = timeout + result.UserAgent = "LokiDeploy" + return result, nil +} + +func clusterClient(cluster *models.Cluster, timeout time.Duration) (kubernetes.Interface, error) { + config, err := clusterRESTConfig(cluster, timeout) + if err != nil { + return nil, err + } + return kubernetes.NewForConfig(config) +} + // BuildKubeconfigFromToken 用 API Server + Token 构造最小 kubeconfig // 优先使用 CA 证书验证 TLS,仅在用户显式要求时跳过证书验证 // Parameters: diff --git a/backend/services/k8s_resources.go b/backend/services/k8s_resources.go new file mode 100644 index 0000000..babd0f5 --- /dev/null +++ b/backend/services/k8s_resources.go @@ -0,0 +1,331 @@ +package services + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + kcScheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/remotecommand" + "opsxloki/models" +) + +func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace string) error { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return err + } + _, err = client.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + _, err = client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{}) + } + return err +} + +// applyManifest 使用 API Server 的 server-side apply,保留已有 YAML 模板与字段所有权。 +func applyManifest(ctx context.Context, cluster *models.Cluster, manifest string) error { + cfg, err := clusterRESTConfig(cluster, 30*time.Second) + if err != nil { + return err + } + dc, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + return err + } + groups, err := restmapper.GetAPIGroupResources(dc) + if err != nil { + return fmt.Errorf("读取 Kubernetes API 资源列表: %w", err) + } + mapper := restmapper.NewDiscoveryRESTMapper(groups) + client, err := dynamic.NewForConfig(cfg) + if err != nil { + return err + } + decoder := yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 4096) + for { + var raw map[string]interface{} + if err := decoder.Decode(&raw); err == io.EOF { + break + } else if err != nil { + return fmt.Errorf("解析 Kubernetes YAML: %w", err) + } + if len(raw) == 0 { + continue + } + obj := &unstructured.Unstructured{Object: raw} + mapping, err := mapper.RESTMapping(obj.GroupVersionKind().GroupKind(), obj.GroupVersionKind().Version) + if err != nil { + return err + } + name := obj.GetName() + if name == "" { + return fmt.Errorf("Kubernetes %s 缺少 metadata.name", obj.GetKind()) + } + data, err := obj.MarshalJSON() + if err != nil { + return err + } + var resource dynamic.ResourceInterface = client.Resource(mapping.Resource) + if mapping.Scope.Name() == "namespace" { + resource = client.Resource(mapping.Resource).Namespace(obj.GetNamespace()) + } + force := true + if _, err := resource.Patch(ctx, name, types.ApplyPatchType, data, metav1.PatchOptions{FieldManager: "lokideploy", Force: &force}); err != nil { + return fmt.Errorf("应用 %s %s/%s: %w", obj.GetKind(), obj.GetNamespace(), name, err) + } + } + return nil +} + +func waitStatefulSet(ctx context.Context, cluster *models.Cluster, namespace, name string) error { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return err + } + limit, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + return wait.PollUntilContextCancel(limit, 3*time.Second, true, func(ctx context.Context) (bool, error) { + item, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + desired := int32(1) + if item.Spec.Replicas != nil { + desired = *item.Spec.Replicas + } + return item.Status.ObservedGeneration >= item.Generation && item.Status.ReadyReplicas >= desired && item.Status.UpdatedReplicas >= desired, nil + }) +} + +func waitDeployment(ctx context.Context, cluster *models.Cluster, namespace, name string) error { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return err + } + limit, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + return wait.PollUntilContextCancel(limit, 3*time.Second, true, func(ctx context.Context) (bool, error) { + item, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + desired := int32(1) + if item.Spec.Replicas != nil { + desired = *item.Spec.Replicas + } + return item.Status.ObservedGeneration >= item.Generation && item.Status.ReadyReplicas >= desired && item.Status.UpdatedReplicas >= desired, nil + }) +} + +func waitDaemonSet(ctx context.Context, cluster *models.Cluster, namespace, name string) error { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return err + } + limit, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + return wait.PollUntilContextCancel(limit, 3*time.Second, true, func(ctx context.Context) (bool, error) { + item, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + return item.Status.ObservedGeneration >= item.Generation && item.Status.NumberReady >= item.Status.DesiredNumberScheduled && item.Status.UpdatedNumberScheduled >= item.Status.DesiredNumberScheduled, nil + }) +} + +func restartDeployment(ctx context.Context, cluster *models.Cluster, namespace, name string) error { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return err + } + item, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + item.Spec.Template.Annotations = copyAnnotations(item.Spec.Template.Annotations) + item.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().UTC().Format(time.RFC3339) + _, err = client.AppsV1().Deployments(namespace).Update(ctx, item, metav1.UpdateOptions{}) + return err +} +func copyAnnotations(source map[string]string) map[string]string { + result := map[string]string{} + for k, v := range source { + result[k] = v + } + return result +} + +func deleteK8sResource(ctx context.Context, cluster *models.Cluster, kind, namespace, name string) error { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return err + } + var result error + switch kind { + case "statefulset": + result = client.AppsV1().StatefulSets(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "deployment": + result = client.AppsV1().Deployments(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "daemonset": + result = client.AppsV1().DaemonSets(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "service": + result = client.CoreV1().Services(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "configmap": + result = client.CoreV1().ConfigMaps(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "serviceaccount": + result = client.CoreV1().ServiceAccounts(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "persistentvolumeclaim": + result = client.CoreV1().PersistentVolumeClaims(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + case "clusterrole": + result = client.RbacV1().ClusterRoles().Delete(ctx, name, metav1.DeleteOptions{}) + case "clusterrolebinding": + result = client.RbacV1().ClusterRoleBindings().Delete(ctx, name, metav1.DeleteOptions{}) + default: + return fmt.Errorf("未知 Kubernetes 资源: %s", kind) + } + if apierrors.IsNotFound(result) { + return nil + } + return result +} + +func serviceClusterIP(ctx context.Context, cluster *models.Cluster, namespace, name string) (string, error) { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return "", err + } + svc, err := client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return svc.Spec.ClusterIP, nil +} +func serviceNodePort(ctx context.Context, cluster *models.Cluster, namespace, name string) (int32, error) { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return 0, err + } + svc, err := client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return 0, err + } + if len(svc.Spec.Ports) == 0 { + return 0, fmt.Errorf("Service %s/%s 没有端口", namespace, name) + } + return svc.Spec.Ports[0].NodePort, nil +} + +func firstNodeIP(ctx context.Context, cluster *models.Cluster) (string, error) { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return "", err + } + nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1}) + if err != nil { + return "", err + } + for _, node := range nodes.Items { + for _, address := range node.Status.Addresses { + if address.Type == corev1.NodeInternalIP { + return address.Address, nil + } + } + } + return "", fmt.Errorf("集群无可用节点内网地址") +} + +func patchDeployment(ctx context.Context, cluster *models.Cluster, namespace, name, yamlPatch string) error { + client, err := clusterClient(cluster, 30*time.Second) + if err != nil { + return err + } + patch, err := yamlutil.ToJSON([]byte(yamlPatch)) + if err != nil { + return err + } + _, err = client.AppsV1().Deployments(namespace).Patch(ctx, name, types.StrategicMergePatchType, patch, metav1.PatchOptions{}) + return err +} + + +// podDNSBroken 在指定 deployment 的容器里探测 hostname 是否可解析(wget /ready 连通性)。 +func podDNSBroken(ctx context.Context, cluster *models.Cluster, namespace, deployment, container, host string) bool { + pods, err := listDeploymentPods(ctx, cluster, namespace, deployment) + if err != nil || len(pods) == 0 { + return false // 拿不到 pod 时不做自愈,交给后续日志排查 + } + client, err := clusterClient(cluster, 15*time.Second) + if err != nil { + return false + } + restCfg, err := clusterRESTConfig(cluster, 15*time.Second) + if err != nil { + return false + } + execReq := client.CoreV1().RESTClient().Post(). + Namespace(namespace).Resource("pods").Name(pods[0]). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Command: []string{"sh", "-c", "wget -q -O /dev/null -T 3 http://" + host + ":3100/ready"}, + Container: container, + Stdout: true, Stderr: true, + }, kcScheme.ParameterCodec) + exec, err := remotecommand.NewSPDYExecutor(restCfg, "POST", execReq.URL()) + if err != nil { + return false + } + var out bytes.Buffer + err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: &out, Stderr: &out}) + return err != nil // 执行失败(连接/解析不通)视为 DNS 故障 +} + +// listDeploymentPods 按 deployment selector 列出 pod 名。 +func listDeploymentPods(ctx context.Context, cluster *models.Cluster, namespace, deployment string) ([]string, error) { + client, err := clusterClient(cluster, 15*time.Second) + if err != nil { + return nil, err + } + dep, err := client.AppsV1().Deployments(namespace).Get(ctx, deployment, metav1.GetOptions{}) + if err != nil { + return nil, err + } + selector := metav1.FormatLabelSelector(dep.Spec.Selector) + podList, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: selector}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(podList.Items)) + for i := range podList.Items { + names = append(names, podList.Items[i].Name) + } + return names, nil +} + + +// lokiNamespaceOf 从 endpoint 的 svc FQDN 提取 Loki 命名空间(log.ns.svc → ns)。 +func lokiNamespaceOf(loki *models.LokiDeployment, endpoint string) string { + if loki != nil && loki.Namespace != "" { + return loki.Namespace + } + parts := strings.SplitN(endpoint, "//", 2) + if len(parts) == 2 { + host := strings.SplitN(parts[1], ".", 3) + if len(host) >= 2 { + return host[1] + } + } + return "log" +} diff --git a/backend/services/ldap.go b/backend/services/ldap.go index c470519..28fce2e 100644 --- a/backend/services/ldap.go +++ b/backend/services/ldap.go @@ -27,17 +27,17 @@ func netDialer() *net.Dialer { // LDAPConfig LDAP 配置(存储形态;BindPassword 以 ENC: 前缀 AES-GCM 加密落库) type LDAPConfig struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - UseSSL bool `json:"useSSL"` - BindDN string `json:"bindDN"` - BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除 - BaseDN string `json:"baseDN"` - UserFilter string `json:"userFilter"` - UsernameAttr string `json:"usernameAttr"` - NameAttr string `json:"nameAttr"` - EmailAttr string `json:"emailAttr"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + UseSSL bool `json:"useSSL"` + BindDN string `json:"bindDN"` + BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除 + BaseDN string `json:"baseDN"` + UserFilter string `json:"userFilter"` + UsernameAttr string `json:"usernameAttr"` + NameAttr string `json:"nameAttr"` + EmailAttr string `json:"emailAttr"` } // LDAPPublicConfig 对外输出形态(密码剔除,附 hasPassword) diff --git a/backend/services/log_policy.go b/backend/services/log_policy.go index 9362d95..6865e87 100644 --- a/backend/services/log_policy.go +++ b/backend/services/log_policy.go @@ -18,7 +18,9 @@ func ListLogPolicyGroups() []models.LogPolicyGroup { groups[i] = group groups[i].UserIDs = append([]uint(nil), group.UserIDs...) groups[i].Labels = make(map[string]string, len(group.Labels)) - for key, value := range group.Labels { groups[i].Labels[key] = value } + for key, value := range group.Labels { + groups[i].Labels[key] = value + } } return groups } diff --git a/backend/services/log_query.go b/backend/services/log_query.go index b05005c..d0fb8dc 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -12,360 +12,34 @@ import ( "io" "net/http" "net/url" - "os/exec" - "regexp" "sort" "strconv" "strings" "time" "opsxloki/models" - "opsxloki/pkg/crypto" "opsxloki/repository" + "k8s.io/client-go/rest" ) const maxLogQueryLimit = 1000 +const logQueryTimeout = 120 * time.Second -// ==================== 数据源 CRUD ==================== - -func GetLogSource(id uint) *models.LogSource { - db := repository.GetDB() - db.RLock() - defer db.RUnlock() - for i := range db.LogSources { - if db.LogSources[i].ID == id { - s := db.LogSources[i] - return &s - } - } - return nil +var lokiHTTPClients = [2]*http.Client{ + {Timeout: logQueryTimeout, Transport: newLokiTransport(false)}, + {Timeout: logQueryTimeout, Transport: newLokiTransport(true)}, } -func ListLogSources() []map[string]interface{} { - db := repository.GetDB() - db.RLock() - defer db.RUnlock() - out := make([]map[string]interface{}, 0, len(db.LogSources)) - for i := range db.LogSources { - out = append(out, db.LogSources[i].Sanitized()) - } - return out -} - -func ListVisibleLogSources(userID uint, admin bool) []map[string]interface{} { - db := repository.GetDB() - db.RLock() - defer db.RUnlock() - out := make([]map[string]interface{}, 0) - for i := range db.LogSources { - s := &db.LogSources[i] - if admin || policyGroupAccess(db.LogPolicyGroups, s.ID, userID) != nil || sourceAccess(s, userID) != nil { - item := s.Sanitized() - if !admin { - item = map[string]interface{}{"id": s.ID, "name": s.Name, "isDefault": s.IsDefault} - } - out = append(out, item) - } - } - return out -} - -func sourceAccess(src *models.LogSource, userID uint) *models.LogSourceAccess { - for i := range src.Access { - if src.Access[i].UserID == userID { - return &src.Access[i] - } - } - return nil -} - -var labelNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) - -func ValidLogLabelName(name string) bool { return labelNamePattern.MatchString(name) } - -// AuthorizedLogQuery constrains the Loki stream selector server-side. Non-admins must be explicitly granted access. -func AuthorizedLogQuery(src *models.LogSource, userID uint, admin bool, query string) (string, error) { - if admin { - return query, nil - } - db := repository.GetDB() - var access *models.LogSourceAccess - if db != nil { - db.RLock() - access = policyGroupAccess(db.LogPolicyGroups, src.ID, userID) - db.RUnlock() - } - if access == nil { - access = sourceAccess(src, userID) - } - if access == nil { - return "", fmt.Errorf("无权查询此数据源") - } - q := strings.TrimSpace(query) - if !strings.HasPrefix(q, "{") { - return "", fmt.Errorf("普通用户查询必须以日志流选择器开头") - } - close := strings.IndexByte(q, '}') - if close < 0 || strings.Contains(q[close+1:], "{") { - return "", fmt.Errorf("查询只能包含一个日志流选择器") - } - if len(access.Labels) == 0 { - return q, nil - } - keys := make([]string, 0, len(access.Labels)) - for k := range access.Labels { - if !labelNamePattern.MatchString(k) { - return "", fmt.Errorf("访问策略包含无效标签名") - } - keys = append(keys, k) - } - sort.Strings(keys) - matchers := make([]string, 0, len(keys)) - for _, k := range keys { - v, _ := json.Marshal(access.Labels[k]) - matchers = append(matchers, k+"="+string(v)) - } - inner := strings.TrimSpace(q[1:close]) - if inner != "" { - inner += "," - } - return "{" + inner + strings.Join(matchers, ",") + "}" + q[close+1:], nil -} - -func CreateLogSource(s *models.LogSource) (*models.LogSource, error) { - if err := encryptSourceCredentials(s); err != nil { - return nil, err - } - db := repository.GetDB() - newID := db.NextID("logSources") - db.Lock() - defer db.Unlock() - s.ID = newID - if s.IsDefault { - for i := range db.LogSources { - db.LogSources[i].IsDefault = false - } - } - // 首个数据源自动设默认 - if len(db.LogSources) == 0 { - s.IsDefault = true - } - db.LogSources = append(db.LogSources, *s) - db.RequestSave() - return s, nil -} - -func encryptSourceCredentials(s *models.LogSource) error { - if s.BasicPass != "" { - v, err := crypto.EncryptField(s.BasicPass) - if err != nil { - return err - } - s.BasicPass = v - } - if s.BearerToken != "" { - v, err := crypto.EncryptField(s.BearerToken) - if err != nil { - return err - } - s.BearerToken = v - } - for name, value := range s.Headers { - if !validSourceHeader(name) { - return fmt.Errorf("自定义 Header 名称无效: %s", name) - } - v, err := crypto.EncryptField(value) - if err != nil { - return err - } - s.Headers[name] = v - } - return nil -} - -func validSourceHeader(name string) bool { - if name == "" || strings.EqualFold(name, "Host") || strings.EqualFold(name, "Authorization") { - return false - } - for _, c := range name { - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { - return false - } - } - return true -} - -func UpdateLogSource(id uint, patch map[string]interface{}) error { - db := repository.GetDB() - db.Lock() - defer db.Unlock() - for i := range db.LogSources { - if db.LogSources[i].ID != id { - continue - } - if v, ok := patch["name"].(string); ok && v != "" { - db.LogSources[i].Name = v - } - if v, ok := patch["namespace"].(string); ok { - db.LogSources[i].Namespace = v - } - if v, ok := patch["service"].(string); ok { - db.LogSources[i].Service = v - } - if v, ok := patch["port"].(float64); ok { - db.LogSources[i].Port = int(v) - } - if v, ok := patch["url"].(string); ok { - db.LogSources[i].URL = v - } - if v, ok := patch["authType"].(string); ok { - db.LogSources[i].AuthType = v - } - if v, ok := patch["basicUser"].(string); ok { - db.LogSources[i].BasicUser = v - } - if v, ok := patch["bearerToken"].(string); ok { - if v != "" { - enc, err := crypto.EncryptField(v) - if err != nil { - return err - } - db.LogSources[i].BearerToken = enc - } - } - if v, ok := patch["skipTLSVerify"].(bool); ok { - db.LogSources[i].SkipTLSVerify = v - } - if raw, ok := patch["headers"]; ok { - data, err := json.Marshal(raw) - if err != nil { - return err - } - var headers map[string]string - if err := json.Unmarshal(data, &headers); err != nil { - return err - } - for name, value := range headers { - if !validSourceHeader(name) { - return fmt.Errorf("自定义 Header 名称无效: %s", name) - } - if value == "" { - if old, ok := db.LogSources[i].Headers[name]; ok { - headers[name] = old - } - continue - } - enc, err := crypto.EncryptField(value) - if err != nil { - return err - } - headers[name] = enc - } - db.LogSources[i].Headers = headers - } - if v, ok := patch["basicPass"].(string); ok && v != "" { - if enc, err := crypto.EncryptField(v); err == nil { - db.LogSources[i].BasicPass = enc - } - } - if v, ok := patch["isDefault"].(bool); ok && v { - for j := range db.LogSources { - db.LogSources[j].IsDefault = db.LogSources[j].ID == id - } - } - if raw, ok := patch["access"]; ok { - data, err := json.Marshal(raw) - if err != nil { - return err - } - var access []models.LogSourceAccess - if err := json.Unmarshal(data, &access); err != nil { - return err - } - if err := ValidateSourceAccess(access); err != nil { - return err - } - db.LogSources[i].Access = access - } - db.LogSources[i].UpdatedAt = time.Now() - db.RequestSave() - return nil - } - return fmt.Errorf("数据源不存在") -} - -func ValidateSourceAccess(access []models.LogSourceAccess) error { - seen := map[uint]bool{} - for _, rule := range access { - if rule.UserID == 0 || seen[rule.UserID] { - return fmt.Errorf("访问策略的用户 ID 无效或重复") - } - seen[rule.UserID] = true - for k := range rule.Labels { - if !labelNamePattern.MatchString(k) { - return fmt.Errorf("访问策略包含无效标签名: %s", k) - } - } - } - return nil -} - -func DeleteLogSource(id uint) error { - db := repository.GetDB() - db.Lock() - defer db.Unlock() - for i := range db.LogSources { - if db.LogSources[i].ID == id { - db.LogSources = append(db.LogSources[:i], db.LogSources[i+1:]...) - groups := db.LogPolicyGroups[:0] - for _, group := range db.LogPolicyGroups { - if group.SourceID != id { - groups = append(groups, group) - } - } - db.LogPolicyGroups = groups - db.RequestSave() - return nil - } - } - return fmt.Errorf("数据源不存在") -} - -// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc) -func EnsureLogSourceForDeployment(d *models.LokiDeployment) { - if d == nil || d.Endpoint == "" { - return - } - db := repository.GetDB() - db.RLock() - for i := range db.LogSources { - if db.LogSources[i].Name == d.Name { - db.RUnlock() - return - } - } - db.RUnlock() - - svc := "loki" - port := 3100 - src := &models.LogSource{ - Name: d.Name, TargetType: "cluster-svc", - ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port, - AuthType: "none", - } - if d.Mode == "host-binary" { - src.TargetType = "url" - src.URL = d.Endpoint - src.ClusterID = nil - } - _, _ = CreateLogSource(src) +func newLokiTransport(skipTLSVerify bool) *http.Transport { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: skipTLSVerify} //nolint:gosec // 数据源显式配置 + return transport } // ==================== Loki HTTP 访问 ==================== -// lokiProxyGET 对数据源执行 GET;cluster-svc 走 kubectl --raw service proxy。 +// lokiProxyGET 对数据源执行 GET;cluster-svc 使用 client-go 的认证传输访问 API Server Service Proxy。 func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query url.Values) ([]byte, int, error) { - // cluster-svc:kubectl get --raw /api/v1/namespaces/{ns}/services/{svc}:{port}/proxy{path}?q if src.TargetType == "cluster-svc" { db := repository.GetDB() db.RLock() @@ -381,25 +55,39 @@ func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query if cluster == nil { return nil, 500, fmt.Errorf("数据源关联的集群不存在") } - env, cleanup, err := kubeconfigEnv(cluster) + if !kubernetesNamespacePattern.MatchString(src.Namespace) || !kubernetesNamespacePattern.MatchString(src.Service) || src.Port < 1 || src.Port > 65535 { + return nil, 500, fmt.Errorf("Loki 数据源的命名空间、Service 或端口无效") + } + config, err := clusterRESTConfig(cluster, logQueryTimeout) if err != nil { return nil, 500, err } - defer cleanup() - - proxyPath := fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy%s", + client, err := rest.HTTPClientFor(config) + if err != nil { + return nil, 500, fmt.Errorf("创建 Kubernetes API 客户端失败: %w", err) + } + full := strings.TrimRight(config.Host, "/") + fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy%s", src.Namespace, src.Service, src.Port, path) - full := proxyPath if qs := query.Encode(); qs != "" { full += "?" + qs } - cmd := exec.CommandContext(ctx, "kubectl", "get", "--raw", full) - cmd.Env = env - out, err := cmd.CombinedOutput() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) if err != nil { - return nil, 502, fmt.Errorf("kubectl 代理查询失败: %v\n%s", err, truncateStr(string(out), 300)) + return nil, 500, err } - return out, 200, nil + resp, err := client.Do(req) + if err != nil { + return nil, 502, fmt.Errorf("Kubernetes API 代理查询失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, 502, err + } + if resp.StatusCode >= 400 { + return body, resp.StatusCode, fmt.Errorf("Kubernetes API 代理返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 300)) + } + return body, resp.StatusCode, nil } // url 直连 @@ -415,9 +103,10 @@ func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query return nil, 500, err } applyLokiAuth(req, src) - client := &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: src.SkipTLSVerify}, - }} + client := lokiHTTPClients[0] + if src.SkipTLSVerify { + client = lokiHTTPClients[1] + } resp, err := client.Do(req) if err != nil { return nil, 502, err @@ -477,6 +166,42 @@ type LogQueryResult struct { NextCursor string `json:"nextCursor"` } +// ContextLogSelector uses stable stream identity labels. Loki can add per-entry +// labels such as detected_level to query results; matching those would hide +// adjacent lines whose detected level differs from the selected line. +func ContextLogSelector(labels map[string]string) (string, error) { + for key := range labels { + if !ValidLogLabelName(key) { + return "", fmt.Errorf("标签名无效") + } + } + keys := []string{"cluster", "namespace", "app", "pod", "container", "filename"} + if labels["pod"] == "" { + keys = append(keys, "job", "instance", "host") + } + parts := make([]string, 0, len(keys)) + for _, key := range keys { + if value := labels[key]; value != "" { + encoded, _ := json.Marshal(value) + parts = append(parts, key+"="+string(encoded)) + } + } + if len(parts) == 0 { + for key, value := range labels { + if value == "" || key == "detected_level" || key == "level" || strings.HasPrefix(key, "__") { + continue + } + encoded, _ := json.Marshal(value) + parts = append(parts, key+"="+string(encoded)) + } + sort.Strings(parts) + } + if len(parts) == 0 { + return "", fmt.Errorf("缺少可用于定位日志流的标签") + } + return "{" + strings.Join(parts, ",") + "}", nil +} + // QueryLokiLogs 日志查询:flatten streams → 统一行;cursor(最后一条纳秒时间戳)向后翻页 func QueryLokiLogs(ctx context.Context, src *models.LogSource, query string, start, end int64, limit int, direction, cursor string) (*LogQueryResult, error) { if limit <= 0 { diff --git a/backend/services/log_query_test.go b/backend/services/log_query_test.go index 3fee85b..045576a 100644 --- a/backend/services/log_query_test.go +++ b/backend/services/log_query_test.go @@ -40,3 +40,17 @@ func TestPolicyGroupAccess(t *testing.T) { t.Fatal("nonmember gained access") } } + +func TestContextLogSelectorIgnoresDetectedLevel(t *testing.T) { + selector, err := ContextLogSelector(map[string]string{ + "cluster": "demo-prod", "namespace": "monitor", "app": "consul", + "pod": "consul-0", "container": "consul", "detected_level": "info", + "filename": "/var/log/pods/consul/1.log", + }) + if err != nil { + t.Fatal(err) + } + if selector != `{cluster="demo-prod",namespace="monitor",app="consul",pod="consul-0",container="consul",filename="/var/log/pods/consul/1.log"}` { + t.Fatalf("unexpected context selector: %s", selector) + } +} diff --git a/backend/services/log_source.go b/backend/services/log_source.go new file mode 100644 index 0000000..654eea2 --- /dev/null +++ b/backend/services/log_source.go @@ -0,0 +1,350 @@ +// 日志数据源管理 — CRUD、默认源、部署自动注册(Managed 标记)。 +package services + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" +) + +// ==================== 数据源 CRUD ==================== + +func GetLogSource(id uint) *models.LogSource { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + for i := range db.LogSources { + if db.LogSources[i].ID == id { + s := db.LogSources[i] + return &s + } + } + return nil +} + +func ListLogSources() []map[string]interface{} { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + out := make([]map[string]interface{}, 0, len(db.LogSources)) + for i := range db.LogSources { + out = append(out, db.LogSources[i].Sanitized()) + } + return out +} + +func ListVisibleLogSources(userID uint, admin bool) []map[string]interface{} { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + out := make([]map[string]interface{}, 0) + for i := range db.LogSources { + s := &db.LogSources[i] + if admin || policyGroupAccess(db.LogPolicyGroups, s.ID, userID) != nil || sourceAccess(s, userID) != nil { + item := s.Sanitized() + if !admin { + item = map[string]interface{}{"id": s.ID, "name": s.Name, "isDefault": s.IsDefault} + } + out = append(out, item) + } + } + return out +} + +func sourceAccess(src *models.LogSource, userID uint) *models.LogSourceAccess { + for i := range src.Access { + if src.Access[i].UserID == userID { + return &src.Access[i] + } + } + return nil +} + +var labelNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +func ValidLogLabelName(name string) bool { return labelNamePattern.MatchString(name) } + +// AuthorizedLogQuery constrains the Loki stream selector server-side. Non-admins must be explicitly granted access. +func AuthorizedLogQuery(src *models.LogSource, userID uint, admin bool, query string) (string, error) { + if admin { + return query, nil + } + db := repository.GetDB() + var access *models.LogSourceAccess + if db != nil { + db.RLock() + access = policyGroupAccess(db.LogPolicyGroups, src.ID, userID) + db.RUnlock() + } + if access == nil { + access = sourceAccess(src, userID) + } + if access == nil { + return "", fmt.Errorf("无权查询此数据源") + } + q := strings.TrimSpace(query) + if !strings.HasPrefix(q, "{") { + return "", fmt.Errorf("普通用户查询必须以日志流选择器开头") + } + close := strings.IndexByte(q, '}') + if close < 0 || strings.Contains(q[close+1:], "{") { + return "", fmt.Errorf("查询只能包含一个日志流选择器") + } + if len(access.Labels) == 0 { + return q, nil + } + keys := make([]string, 0, len(access.Labels)) + for k := range access.Labels { + if !labelNamePattern.MatchString(k) { + return "", fmt.Errorf("访问策略包含无效标签名") + } + keys = append(keys, k) + } + sort.Strings(keys) + matchers := make([]string, 0, len(keys)) + for _, k := range keys { + v, _ := json.Marshal(access.Labels[k]) + matchers = append(matchers, k+"="+string(v)) + } + inner := strings.TrimSpace(q[1:close]) + if inner != "" { + inner += "," + } + return "{" + inner + strings.Join(matchers, ",") + "}" + q[close+1:], nil +} + +func CreateLogSource(s *models.LogSource) (*models.LogSource, error) { + if err := encryptSourceCredentials(s); err != nil { + return nil, err + } + db := repository.GetDB() + newID := db.NextID("logSources") + db.Lock() + defer db.Unlock() + s.ID = newID + if s.IsDefault { + for i := range db.LogSources { + db.LogSources[i].IsDefault = false + } + } + // 首个数据源自动设默认 + if len(db.LogSources) == 0 { + s.IsDefault = true + } + db.LogSources = append(db.LogSources, *s) + db.RequestSave() + return s, nil +} + +func encryptSourceCredentials(s *models.LogSource) error { + if s.BasicPass != "" { + v, err := crypto.EncryptField(s.BasicPass) + if err != nil { + return err + } + s.BasicPass = v + } + if s.BearerToken != "" { + v, err := crypto.EncryptField(s.BearerToken) + if err != nil { + return err + } + s.BearerToken = v + } + for name, value := range s.Headers { + if !validSourceHeader(name) { + return fmt.Errorf("自定义 Header 名称无效: %s", name) + } + v, err := crypto.EncryptField(value) + if err != nil { + return err + } + s.Headers[name] = v + } + return nil +} + +func validSourceHeader(name string) bool { + if name == "" || strings.EqualFold(name, "Host") || strings.EqualFold(name, "Authorization") { + return false + } + for _, c := range name { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return false + } + } + return true +} + +func UpdateLogSource(id uint, patch map[string]interface{}) error { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.LogSources { + if db.LogSources[i].ID != id { + continue + } + if v, ok := patch["name"].(string); ok && v != "" { + db.LogSources[i].Name = v + } + if v, ok := patch["namespace"].(string); ok { + db.LogSources[i].Namespace = v + } + if v, ok := patch["service"].(string); ok { + db.LogSources[i].Service = v + } + if v, ok := patch["port"].(float64); ok { + db.LogSources[i].Port = int(v) + } + if v, ok := patch["url"].(string); ok { + db.LogSources[i].URL = v + } + if v, ok := patch["authType"].(string); ok { + db.LogSources[i].AuthType = v + } + if v, ok := patch["basicUser"].(string); ok { + db.LogSources[i].BasicUser = v + } + if v, ok := patch["bearerToken"].(string); ok { + if v != "" { + enc, err := crypto.EncryptField(v) + if err != nil { + return err + } + db.LogSources[i].BearerToken = enc + } + } + if v, ok := patch["skipTLSVerify"].(bool); ok { + db.LogSources[i].SkipTLSVerify = v + } + if raw, ok := patch["headers"]; ok { + data, err := json.Marshal(raw) + if err != nil { + return err + } + var headers map[string]string + if err := json.Unmarshal(data, &headers); err != nil { + return err + } + for name, value := range headers { + if !validSourceHeader(name) { + return fmt.Errorf("自定义 Header 名称无效: %s", name) + } + if value == "" { + if old, ok := db.LogSources[i].Headers[name]; ok { + headers[name] = old + } + continue + } + enc, err := crypto.EncryptField(value) + if err != nil { + return err + } + headers[name] = enc + } + db.LogSources[i].Headers = headers + } + if v, ok := patch["basicPass"].(string); ok && v != "" { + if enc, err := crypto.EncryptField(v); err == nil { + db.LogSources[i].BasicPass = enc + } + } + if v, ok := patch["isDefault"].(bool); ok && v { + for j := range db.LogSources { + db.LogSources[j].IsDefault = db.LogSources[j].ID == id + } + } + if raw, ok := patch["access"]; ok { + data, err := json.Marshal(raw) + if err != nil { + return err + } + var access []models.LogSourceAccess + if err := json.Unmarshal(data, &access); err != nil { + return err + } + if err := ValidateSourceAccess(access); err != nil { + return err + } + db.LogSources[i].Access = access + } + db.LogSources[i].UpdatedAt = time.Now() + db.RequestSave() + return nil + } + return fmt.Errorf("数据源不存在") +} + +func ValidateSourceAccess(access []models.LogSourceAccess) error { + seen := map[uint]bool{} + for _, rule := range access { + if rule.UserID == 0 || seen[rule.UserID] { + return fmt.Errorf("访问策略的用户 ID 无效或重复") + } + seen[rule.UserID] = true + for k := range rule.Labels { + if !labelNamePattern.MatchString(k) { + return fmt.Errorf("访问策略包含无效标签名: %s", k) + } + } + } + return nil +} + +func DeleteLogSource(id uint) error { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.LogSources { + if db.LogSources[i].ID == id { + db.LogSources = append(db.LogSources[:i], db.LogSources[i+1:]...) + groups := db.LogPolicyGroups[:0] + for _, group := range db.LogPolicyGroups { + if group.SourceID != id { + groups = append(groups, group) + } + } + db.LogPolicyGroups = groups + db.RequestSave() + return nil + } + } + return fmt.Errorf("数据源不存在") +} + +// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc) +func EnsureLogSourceForDeployment(d *models.LokiDeployment) { + if d == nil || d.Endpoint == "" { + return + } + db := repository.GetDB() + db.RLock() + for i := range db.LogSources { + if db.LogSources[i].Name == d.Name { + db.RUnlock() + return + } + } + db.RUnlock() + + svc := "loki" + port := 3100 + src := &models.LogSource{ + Name: d.Name, TargetType: "cluster-svc", + ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port, + AuthType: "none", + } + if d.Mode == "host-binary" || d.Mode == "host-docker" { + src.TargetType = "url" + src.URL = d.Endpoint + src.ClusterID = nil + } + _, _ = CreateLogSource(src) +} diff --git a/backend/services/loki_config.go b/backend/services/loki_config.go new file mode 100644 index 0000000..08d777f --- /dev/null +++ b/backend/services/loki_config.go @@ -0,0 +1,370 @@ +// LokiDeployer — loki.yaml 配置生成:模板 + 对象存储/规则存储/ring 拓扑。 +package services + +import ( + "bytes" + "fmt" + "text/template" +) + +const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }} + +common: + path_prefix: /var/lib/loki +{{- if .FilesystemStorage }} + storage: + filesystem: + chunks_directory: /var/lib/loki/chunks + rules_directory: /var/lib/loki/rules +{{- end }} + replication_factor: {{ .ReplicationFactor }} + ring: + instance_addr: {{ .RingInstanceAddr }} + kvstore: + store: {{ .RingKVStore }} + +{{- if .StorageConfigBlock }} + +storage_config: +{{ .StorageConfigBlock }} +{{- end }} +{{- if .MemberlistBlock }} + +{{ .MemberlistBlock }} +{{- end }} + +ingester: + wal: + dir: /var/lib/loki/wal + flush_on_shutdown: true + replay_memory_ceiling: 1GB + lifecycler: + ring: + kvstore: + store: {{ .RingKVStore }} + replication_factor: {{ .ReplicationFactor }} +{{- if .MultiReplica }} + autoforget_unhealthy: true +{{- end }} + +compactor: + working_directory: /var/lib/loki/compactor + retention_enabled: true + delete_request_store: {{ .CompactorStoreType }} + +limits_config: + retention_period: {{ .RetentionPeriod }}h + +schema_config: + configs: + - from: "2024-01-01" + store: tsdb + object_store: {{ .ObjectStore }} + schema: v13 + index: + prefix: tsdb_index_ + period: 24h + +ruler: + storage: + type: {{ .RulerStoreType }} +{{- if .RulerStorageBlock }} +{{ .RulerStorageBlock }} +{{- end }} +{{- if eq .RulerStoreType "local" }} + local: + directory: /var/lib/loki/rules +{{- end }} + rule_path: /var/lib/loki/rules-temp + ring: + kvstore: + store: {{ .RingKVStore }} + +analytics: + reporting_enabled: false +` + +type lokiConfigData struct { + AuthEnabled bool + ReplicationFactor int + RetentionPeriod int + StorageConfigBlock string + ObjectStore string + RulerStoreType string + CompactorStoreType string + RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址) + RingKVStore string // 单副本 inmemory;多副本 memberlist + MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置 + RulerStorageBlock string // 对象存储时非空,注入 ruler.storage.s3/gcs/azure 嵌套配置 + MultiReplica bool + FilesystemStorage bool +} + +func (d *LokiDeployer) generateLokiConfig() string { + retentionHours := d.deployment.RetentionDays * 24 + if retentionHours <= 0 { + retentionHours = DefaultRetentionDays * 24 + } + + storageType := d.effectiveStorageType() + storageBlock := d.buildStorageConfigBlock() + objectStore := "filesystem" + rulerStore := "local" + compactorStore := "filesystem" + + switch storageType { + case "s3", "oss", "minio": + objectStore = "s3" + rulerStore = "s3" + compactorStore = "s3" + case "gcs": + objectStore = "gcs" + rulerStore = "gcs" + compactorStore = "gcs" + case "azure": + objectStore = "azure" + rulerStore = "azure" + compactorStore = "azure" + } + + // Ring 拓扑:单副本用 inmemory + 127.0.0.1(无需跨实例通信); + // 多副本必须切 memberlist + 0.0.0.0,否则 ring 无法收敛、写入失败。 + ringAddr := "127.0.0.1" + ringStore := "inmemory" + memberlistBlock := "" + if d.deployment.Replicas >= 2 { + ringAddr = "0.0.0.0" + ringStore = "memberlist" + ns := d.deployment.Namespace + if ns == "" { + ns = "log" + } + memberlistBlock = fmt.Sprintf(`memberlist: + join_members: + - loki-headless.%s.svc.cluster.local + bind_port: 7946 + dead_node_reclaim_time: 30s + gossip_to_dead_nodes_time: 2m + left_ingesters_timeout: 5m`, ns) + } + + data := lokiConfigData{ + AuthEnabled: d.deployment.AuthEnabled, + ReplicationFactor: d.replicationFactor(), + RetentionPeriod: retentionHours, + StorageConfigBlock: storageBlock, + ObjectStore: objectStore, + RulerStoreType: rulerStore, + CompactorStoreType: compactorStore, + RingInstanceAddr: ringAddr, + RingKVStore: ringStore, + MemberlistBlock: memberlistBlock, + RulerStorageBlock: d.buildRulerStorageBlock(), + MultiReplica: d.deployment.Replicas >= 2, + FilesystemStorage: objectStore == "filesystem", + } + + tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate) + if err != nil { + return d.generateLokiConfigFallback(retentionHours) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return d.generateLokiConfigFallback(retentionHours) + } + return buf.String() +} + +func (d *LokiDeployer) buildStorageConfigBlock() string { + if d.deployment.StorageConfig == nil { + return "" + } + + sc := d.deployment.StorageConfig + accessKey, secretKey := d.storageCredentialValues() + switch sc.StorageType { + case "s3": + endpoint := sc.Endpoint + if endpoint == "" { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) + } + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` aws: + bucketnames: %s + endpoint: %s + region: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure) + case "oss": + return fmt.Sprintf(` alibabacloud: + bucket: %s + endpoint: %s + region: %s + access_key_id: %s + secret_access_key: %s`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey) + case "minio": + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` aws: + bucketnames: %s + endpoint: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey, insecure) + case "gcs": + return fmt.Sprintf(` gcs: + bucket_name: %s`, sc.Bucket) + case "azure": + return fmt.Sprintf(` azure: + container_name: %s + account_name: %s + account_key: %s`, sc.Bucket, accessKey, secretKey) + default: + return "" + } +} + +// buildRulerStorageBlock 生成 ruler.storage 下与存储类型同名的嵌套配置 +// (Loki 3.x 的 ruler 存储客户端不回退读取 storage_config,缺少此块会以空 bucket 启动失败) +func (d *LokiDeployer) buildRulerStorageBlock() string { + if d.deployment.StorageConfig == nil { + return "" + } + sc := d.deployment.StorageConfig + accessKey, secretKey := d.storageCredentialValues() + switch sc.StorageType { + case "s3": + endpoint := sc.Endpoint + if endpoint == "" { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) + } + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` s3: + bucketnames: %s + endpoint: %s + region: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure) + case "oss": + return fmt.Sprintf(` s3: + bucketnames: %s + endpoint: %s + access_key_id: %s + secret_access_key: %s + s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey) + case "minio": + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` s3: + bucketnames: %s + endpoint: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey, insecure) + case "gcs": + return fmt.Sprintf(` gcs: + bucket_name: %s`, sc.Bucket) + case "azure": + return fmt.Sprintf(` azure: + container_name: %s + account_name: %s + account_key: %s`, sc.Bucket, accessKey, secretKey) + default: + return "" + } +} + +func (d *LokiDeployer) storageCredentialValues() (string, string) { + if d.deployment.StorageConfig == nil { + return "", "" + } + if d.deployment.Mode == "k8s-yaml" || d.deployment.Mode == "host-docker" { + return "${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}" + } + return d.deployment.StorageConfig.AccessKey, d.deployment.StorageConfig.SecretKey +} + +// generateLokiConfigFallback 当模板解析失败时的降级配置 +func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string { + return fmt.Sprintf(`auth_enabled: %v +server: + http_listen_port: 3100 + grpc_listen_port: 9095 + http_server_read_timeout: 3m + http_server_write_timeout: 3m + log_level: info +common: + path_prefix: /var/lib/loki + storage: + filesystem: + chunks_directory: /var/lib/loki/chunks + rules_directory: /var/lib/loki/rules + replication_factor: %d + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory +ingester: + wal: + enabled: true + dir: /var/lib/loki/wal + lifecycler: + ring: + kvstore: + store: inmemory + replication_factor: %d + final_sleep: 0s + chunk_idle_period: 30m + max_chunk_age: 2h +querier: + max_concurrent: 10 +compactor: + working_directory: /var/lib/loki/compactor + retention_enabled: true + retention_delete_delay: 2h +limits_config: + retention_period: %dh + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 +schema_config: + configs: + - from: "2024-01-01" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: tsdb_index_ + period: 24h +analytics: + reporting_enabled: false +`, d.deployment.AuthEnabled, d.replicationFactor(), d.replicationFactor(), retentionHours) +} + +// ==================== 辅助函数 ==================== + +func (d *LokiDeployer) replicationFactor() int { + if d.deployment.Replicas >= 3 { + return 3 + } + if d.deployment.Replicas >= 2 { + return 2 + } + return 1 +} diff --git a/backend/services/loki_deployer.go b/backend/services/loki_deployer.go index 0aed55c..d0dc59e 100644 --- a/backend/services/loki_deployer.go +++ b/backend/services/loki_deployer.go @@ -1,20 +1,17 @@ package services import ( - "bytes" "context" "fmt" "os" - "os/exec" "strconv" "strings" - "text/template" "opsxloki/models" "opsxloki/pkg/crypto" ) -// LokiDeployer 负责 Loki 的三种部署模式:k8s-helm / k8s-yaml / host-binary +// LokiDeployer 负责 Loki 的 Kubernetes、主机 Docker 及历史部署模式。 type LokiDeployer struct { deployment *models.LokiDeployment logFunc func(level, message, detail string) @@ -26,6 +23,13 @@ func NewLokiDeployer(deployment *models.LokiDeployment, logFunc func(level, mess // Deploy 根据部署模式分发到对应实现 func (d *LokiDeployer) Deploy(ctx context.Context) error { + if sc := d.deployment.StorageConfig; sc != nil { + for _, credential := range []string{sc.AccessKey, sc.SecretKey} { + if strings.HasPrefix(credential, "ENC:") { + return fmt.Errorf("存储凭证解密失败,请检查 ENCRYPTION_KEY 并重新保存存储配置") + } + } + } d.log("info", fmt.Sprintf("Starting Loki deployment: %s (mode: %s, version: %s)", d.deployment.Name, d.deployment.Mode, d.effectiveVersion()), "") @@ -36,12 +40,12 @@ func (d *LokiDeployer) Deploy(ctx context.Context) error { var err error switch d.deployment.Mode { - case "k8s-helm": - err = d.deployHelm(ctx) case "k8s-yaml": err = d.deployYaml(ctx) case "host-binary": err = d.deployBinary(ctx) + case "host-docker": + err = d.deployDocker(ctx) default: return fmt.Errorf("unsupported deployment mode: %s", d.deployment.Mode) } @@ -49,80 +53,6 @@ func (d *LokiDeployer) Deploy(ctx context.Context) error { return err } - // Loki 就绪后,按需同环境部署 Grafana(数据源自动指向本 Loki) - if d.deployment.GrafanaEnabled { - g := &GrafanaDeployer{deployment: d.deployment, log: d.log} - if gerr := g.Deploy(ctx); gerr != nil { - d.log("error", fmt.Sprintf("Grafana deployment failed: %v", gerr), "") - return fmt.Errorf("grafana deployment failed: %w", gerr) - } - } - return nil -} - -// ==================== Helm 部署 (grafana/loki v6+) ==================== - -func (d *LokiDeployer) deployHelm(ctx context.Context) error { - if d.deployment.Cluster == nil { - return fmt.Errorf("please select target cluster") - } - - kubeconfig, err := resolveKubeconfig(d.deployment.Cluster) - if err != nil { - return err - } - - kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) - if err != nil { - return err - } - defer cleanup() - - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) - ns := d.effectiveNamespace() - - // 创建 namespace(幂等) - d.log("info", fmt.Sprintf("Ensuring namespace %s...", ns), "") - nsCmd := exec.CommandContext(ctx, "kubectl", "create", "namespace", ns, "--dry-run=client", "-o", "yaml") - nsCmd.Env = env - nsOut, _ := nsCmd.CombinedOutput() - applyNs := exec.CommandContext(ctx, "kubectl", "apply", "-f", "-") - applyNs.Env = env - applyNs.Stdin = bytes.NewReader(nsOut) - applyNs.CombinedOutput() - - // 添加 Helm repo - d.log("info", "Adding/updating Grafana Helm repo...", "") - repoCmd := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts") - repoCmd.Env = env - repoCmd.CombinedOutput() - updCmd := exec.CommandContext(ctx, "helm", "repo", "update") - updCmd.Env = env - updCmd.CombinedOutput() - - // 生成 values.yaml - valuesYAML := d.generateHelmValues() - valuesFile, valuesCleanup, err := writeTempFile("loki-values-*.yaml", valuesYAML, 0600) - if err != nil { - return err - } - defer valuesCleanup() - - // Helm install/upgrade - 统一使用 grafana/loki (v6+) - d.log("info", fmt.Sprintf("Installing Loki via Helm (grafana/loki, mode: %s)...", d.helmDeploymentMode()), "") - releaseName := d.effectiveReleaseName() - - cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", releaseName, "grafana/loki", - "--namespace", ns, "--create-namespace", "--wait", "--timeout", "10m", - "-f", valuesFile, - ) - cmd.Env = env - out, err := cmd.CombinedOutput() - if err != nil { - d.log("error", fmt.Sprintf("Helm install failed: %v", err), string(out)) - return fmt.Errorf("helm install failed: %w\n%s", err, string(out)) - } - d.log("success", "Loki installed via Helm", string(out)) return nil } @@ -132,47 +62,26 @@ func (d *LokiDeployer) deployYaml(ctx context.Context) error { if d.deployment.Cluster == nil { return fmt.Errorf("please select target cluster") } - - kubeconfig, err := resolveKubeconfig(d.deployment.Cluster) - if err != nil { - return err - } - - kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) - if err != nil { - return err - } - defer cleanup() - - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) ns := d.effectiveNamespace() - - // 生成完整 manifest - manifest := d.generateManifest(ns) - manifestFile, manifestCleanup, err := writeTempFile("loki-*.yaml", manifest, 0600) - if err != nil { + if err := ensureNamespace(ctx, d.deployment.Cluster, ns); err != nil { + return fmt.Errorf("创建命名空间 %s: %w", ns, err) + } + d.log("info", "Applying Loki manifest (StatefulSet + PVC + ConfigMap + Service)...", "") + if err := applyManifest(ctx, d.deployment.Cluster, d.generateManifest(ns)); err != nil { return err } - defer manifestCleanup() - - d.log("info", "Applying Loki manifest (StatefulSet + PVC + ConfigMap + Service)...", "") - cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile) - cmd.Env = env - out, err := cmd.CombinedOutput() - if err != nil { - d.log("error", fmt.Sprintf("kubectl apply failed: %v", err), string(out)) - return fmt.Errorf("kubectl apply failed: %w\n%s", err, string(out)) - } - - // 等待 StatefulSet ready d.log("info", "Waiting for StatefulSet to be ready...", "") - waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "statefulset/loki", - "-n", ns, "--timeout=5m") - waitCmd.Env = env - waitOut, _ := waitCmd.CombinedOutput() - d.log("info", string(waitOut), "") - - d.log("success", "Loki deployed via YAML manifest", string(out)) + if err := waitStatefulSet(ctx, d.deployment.Cluster, ns, "loki"); err != nil { + return fmt.Errorf("Loki StatefulSet 未就绪: %w", err) + } + // Ingress access log belongs to the target cluster, not the Loki namespace. + // Its absence or lack of permission must not invalidate a healthy Loki deployment. + if _, err := ApplyIngressAccess(ctx, d.deployment.Cluster); err != nil { + d.log("warn", "未能自动配置 ingress-nginx 访问日志", err.Error()) + } else { + d.log("info", "已配置 ingress-nginx JSON 访问日志,包含请求 Host", "") + } + d.log("success", "Loki deployed via Kubernetes API", "") return nil } @@ -262,820 +171,6 @@ WantedBy=multi-user.target // ==================== Helm Values 生成 ==================== -func (d *LokiDeployer) generateHelmValues() string { - mode := d.helmDeploymentMode() - version := d.effectiveVersion() - - // 构建 storage 配置 - storageSection := d.buildHelmStorageSection() - schemaSection := d.buildHelmSchemaSection() - - // 资源限制 + 持久化(合并到同一顶层键下,避免重复键导致 Helm 丢弃 resources) - deploymentSection := d.buildHelmDeploymentSection() - - // 认证配置 - authSection := "" - if d.deployment.AuthEnabled { - authSection = ` -auth: - type: basic - basic: - username: admin` - } - - values := fmt.Sprintf(`# Auto-generated Loki Helm Values -# Chart: grafana/loki (v6+) -# Deployment Mode: %s -# Version: %s - -deploymentMode: %s - -loki: - image: - tag: %s - auth_enabled: %v - commonConfig: - replication_factor: %d - path_prefix: /var/loki - %s - limits_config: - retention_period: %dh - ingestion_rate_mb: 16 - ingestion_burst_size_mb: 32 - per_stream_rate_limit: 3MB - per_stream_rate_limit_burst: 15MB - max_chunks_per_query: 2000000 - max_query_series: 1000 - max_query_parallelism: 32 - %s - %s - analytics: - reporting_enabled: false - -%s - -monitoring: - serviceMonitor: - enabled: false - selfMonitoring: - enabled: false - grafanaAgent: - installOperator: false - lokiCanary: - enabled: false - -test: - enabled: false -`, mode, version, mode, version, d.deployment.AuthEnabled, - d.replicationFactor(), storageSection, d.deployment.RetentionDays, schemaSection, - authSection, deploymentSection) - - return values -} - -func (d *LokiDeployer) helmDeploymentMode() string { - if d.deployment.Replicas > 1 { - return "Distributed" - } - return "SingleBinary" -} - -func (d *LokiDeployer) replicationFactor() int { - if d.deployment.Replicas >= 3 { - return 3 - } - if d.deployment.Replicas >= 2 { - return 2 - } - return 1 -} - -func (d *LokiDeployer) buildHelmStorageSection() string { - storageType := d.effectiveStorageType() - - switch storageType { - case "s3": - sc := d.deployment.StorageConfig - endpoint := sc.Endpoint - if endpoint == "" { - endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) - } - return fmt.Sprintf(`storage: - type: s3 - bucketNames: - chunks: %s - ruler: %s-ruler - admin: %s-admin - s3: - endpoint: %s - region: %s - accessKeyId: %s - secretAccessKey: %s - insecure: %v - s3ForcePathStyle: true`, sc.Bucket, sc.Bucket, sc.Bucket, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, sc.Insecure) - case "oss": - sc := d.deployment.StorageConfig - return fmt.Sprintf(`storage: - type: s3 - bucketNames: - chunks: %s - ruler: %s-ruler - admin: %s-admin - s3: - endpoint: %s - region: %s - accessKeyId: %s - secretAccessKey: %s`, sc.Bucket, sc.Bucket, sc.Bucket, sc.Endpoint, sc.Region, sc.AccessKey, sc.SecretKey) - case "minio": - sc := d.deployment.StorageConfig - return fmt.Sprintf(`storage: - type: s3 - bucketNames: - chunks: %s - ruler: %s-ruler - admin: %s-admin - s3: - endpoint: %s - accessKeyId: %s - secretAccessKey: %s - insecure: %v - s3ForcePathStyle: true`, sc.Bucket, sc.Bucket, sc.Bucket, sc.Endpoint, sc.AccessKey, sc.SecretKey, sc.Insecure) - default: - // filesystem - return `storage: - type: filesystem` - } -} - -func (d *LokiDeployer) buildHelmSchemaSection() string { - storageType := d.effectiveStorageType() - objectStore := "filesystem" - switch storageType { - case "s3", "oss", "minio": - objectStore = "s3" - case "gcs": - objectStore = "gcs" - case "azure": - objectStore = "azure" - } - - return fmt.Sprintf(`schemaConfig: - configs: - - from: "2024-01-01" - store: tsdb - object_store: %s - schema: v13 - index: - prefix: tsdb_index_ - period: 24h`, objectStore) -} - -// buildHelmDeploymentSection 生成目标组件的 resources + persistence。 -// 关键:必须把两者合并到同一顶层键(singleBinary / write / read / backend)之下, -// 否则 resources 与 persistence 各自 emit 一次同名顶层键,YAML/Helm 只取最后一个, -// 导致 resources 限制被静默丢弃。 -func (d *LokiDeployer) buildHelmDeploymentSection() string { - cpu := d.deployment.ResourcesCPU - mem := d.deployment.ResourcesMem - if cpu == "" { - cpu = "1000m" - } - if mem == "" { - mem = "1Gi" - } - storageSize := d.deployment.StorageSize - if storageSize == "" { - storageSize = "10Gi" - } - - resources := fmt.Sprintf(` resources: - limits: - cpu: %s - memory: %s - requests: - cpu: 200m - memory: 256Mi`, cpu, mem) - - persistence := fmt.Sprintf(` persistence: - enabled: true - size: %s - storageClassName: ""`, storageSize) - - if d.helmDeploymentMode() == "SingleBinary" { - return fmt.Sprintf(`singleBinary: - replicas: %d -%s -%s`, d.deployment.Replicas, resources, persistence) - } - - // Distributed: write/backend 持久化,read 不持久化(无状态) - return fmt.Sprintf(`write: - replicas: %d -%s -%s -read: - replicas: %d -%s - persistence: - enabled: false -backend: - replicas: 1 -%s -%s`, - d.deployment.Replicas, resources, persistence, - d.deployment.Replicas, resources, - resources, persistence) -} - -// ==================== K8s YAML Manifest 生成 (StatefulSet) ==================== - -func (d *LokiDeployer) generateManifest(ns string) string { - version := d.effectiveVersion() - replicas := d.deployment.Replicas - if replicas < 1 { - replicas = 1 - } - storageSize := d.deployment.StorageSize - if storageSize == "" { - storageSize = "10Gi" - } - - cpu := d.deployment.ResourcesCPU - mem := d.deployment.ResourcesMem - if cpu == "" { - cpu = "1000m" - } - if mem == "" { - mem = "1Gi" - } - - configData := d.generateLokiConfig() - - manifest := fmt.Sprintf(`apiVersion: v1 -kind: Namespace -metadata: - name: %s ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: loki-config - namespace: %s -data: - loki.yaml: | -%s ---- -apiVersion: v1 -kind: Service -metadata: - name: loki - namespace: %s - labels: - app: loki -spec: - type: ClusterIP - ports: - - name: http - port: 3100 - targetPort: 3100 - protocol: TCP - - name: grpc - port: 9095 - targetPort: 9095 - protocol: TCP - selector: - app: loki ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: loki-data - namespace: %s -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: %s ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: loki - namespace: %s - labels: - app: loki -spec: - serviceName: loki - replicas: %d - selector: - matchLabels: - app: loki - template: - metadata: - labels: - app: loki - spec: - securityContext: - runAsUser: 0 - runAsGroup: 0 - fsGroup: 0 - containers: - - name: loki - image: grafana/loki:%s - imagePullPolicy: IfNotPresent - args: - - -config.file=/etc/loki/loki.yaml - ports: - - name: http - containerPort: 3100 - protocol: TCP - - name: grpc - containerPort: 9095 - protocol: TCP - resources: - limits: - cpu: "%s" - memory: "%s" - requests: - cpu: "200m" - memory: "256Mi" - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: false - capabilities: - drop: - - ALL - livenessProbe: - httpGet: - path: /ready - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 6 - readinessProbe: - httpGet: - path: /ready - port: http - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - startupProbe: - httpGet: - path: /ready - port: http - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 30 - volumeMounts: - - name: config - mountPath: /etc/loki - readOnly: true - - name: data - mountPath: /var/lib/loki - - name: wal - mountPath: /var/lib/loki/wal - volumes: - - name: config - configMap: - name: loki-config - - name: data - persistentVolumeClaim: - claimName: loki-data - - name: wal - emptyDir: {} -`, ns, ns, indentYAML(configData, 4), ns, ns, storageSize, ns, replicas, version, cpu, mem) - - // 多副本:追加 Headless Service(clusterIP: None + selector app:loki), - // 使 memberlist 的 join_members (loki-headless..svc.cluster.local) 能解析到所有 Pod IP。 - if replicas >= 2 { - manifest += fmt.Sprintf(` ---- -apiVersion: v1 -kind: Service -metadata: - name: loki-headless - namespace: %s - labels: - app: loki -spec: - clusterIP: None - ports: - - name: http - port: 3100 - targetPort: 3100 - protocol: TCP - - name: grpc - port: 9095 - targetPort: 9095 - protocol: TCP - - name: memberlist - port: 7946 - targetPort: 7946 - protocol: TCP - selector: - app: loki -`, ns) - } - return manifest -} - -// ==================== Loki 本地配置生成 (text/template) ==================== - -const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }} - -server: - http_listen_port: 3100 - grpc_listen_port: 9095 - http_server_read_timeout: 3m - http_server_write_timeout: 3m - http_server_idle_timeout: 2m - grpc_server_max_recv_msg_size: 104857600 - grpc_server_max_send_msg_size: 104857600 - grpc_server_max_concurrent_streams: 1000 - log_level: info - log_format: logfmt - -common: - path_prefix: /var/lib/loki - storage: - filesystem: - chunks_directory: /var/lib/loki/chunks - rules_directory: /var/lib/loki/rules - replication_factor: {{ .ReplicationFactor }} - ring: - instance_addr: {{ .RingInstanceAddr }} - kvstore: - store: {{ .RingKVStore }} - -{{- if .StorageConfigBlock }} - -storage_config: -{{ .StorageConfigBlock }} -{{- end }} -{{- if .MemberlistBlock }} - -{{ .MemberlistBlock }} -{{- end }} - -ingester: - wal: - enabled: true - dir: /var/lib/loki/wal - flush_on_shutdown: true - replay_memory_ceiling: 1GB - lifecycler: - ring: - kvstore: - store: {{ .RingKVStore }} - replication_factor: {{ .ReplicationFactor }} - final_sleep: 0s - chunk_idle_period: 30m - chunk_retain_period: 30s - chunk_target_size: 1048576 - chunk_encoding: snappy - max_chunk_age: 2h - autoforget_unhealthy: true - -querier: - max_concurrent: 10 - query_ingesters_within: 2h - -query_scheduler: - max_outstanding_requests_per_tenant: 4096 - -compactor: - working_directory: /var/lib/loki/compactor - compaction_interval: 10m - retention_enabled: true - retention_delete_delay: 2h - retention_delete_worker_count: 150 - delete_request_store: {{ .CompactorStoreType }} - -limits_config: - retention_period: {{ .RetentionPeriod }}h - ingestion_rate_mb: 16 - ingestion_burst_size_mb: 32 - per_stream_rate_limit: 3MB - per_stream_rate_limit_burst: 15MB - max_chunks_per_query: 2000000 - max_query_series: 1000 - max_query_parallelism: 32 - max_entries_limit_per_query: 5000 - max_label_name_length: 1024 - max_label_value_length: 2048 - max_label_names_per_series: 30 - reject_old_samples: true - reject_old_samples_max_age: 168h - allow_structured_metadata: true - -schema_config: - configs: - - from: "2024-01-01" - store: tsdb - object_store: {{ .ObjectStore }} - schema: v13 - index: - prefix: tsdb_index_ - period: 24h - -ruler: - storage: - type: {{ .RulerStoreType }} -{{- if .RulerStorageBlock }} -{{ .RulerStorageBlock }} -{{- end }} -{{- if eq .RulerStoreType "local" }} - local: - directory: /var/lib/loki/rules -{{- end }} - rule_path: /var/lib/loki/rules-temp - alertmanager_url: http://localhost:9093 - ring: - kvstore: - store: {{ .RingKVStore }} - enable_api: true - enable_alertmanager_v2: true - -frontend: - max_outstanding_per_tenant: 4096 - compress_responses: true - -frontend_worker: {} - -chunk_store_config: - chunk_cache_config: - embedded_cache: - enabled: true - max_size_mb: 256 - ttl: 1h - -analytics: - reporting_enabled: false -` - -type lokiConfigData struct { - AuthEnabled bool - ReplicationFactor int - RetentionPeriod int - StorageConfigBlock string - ObjectStore string - RulerStoreType string - CompactorStoreType string - RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址) - RingKVStore string // 单副本 inmemory;多副本 memberlist - MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置 - RulerStorageBlock string // 对象存储时非空,注入 ruler.storage.s3/gcs/azure 嵌套配置 -} - -func (d *LokiDeployer) generateLokiConfig() string { - retentionHours := d.deployment.RetentionDays * 24 - if retentionHours <= 0 { - retentionHours = 720 // 30 days default - } - - storageType := d.effectiveStorageType() - storageBlock := d.buildStorageConfigBlock() - objectStore := "filesystem" - rulerStore := "local" - compactorStore := "filesystem" - - switch storageType { - case "s3", "oss", "minio": - objectStore = "s3" - rulerStore = "s3" - compactorStore = "s3" - case "gcs": - objectStore = "gcs" - rulerStore = "gcs" - compactorStore = "gcs" - case "azure": - objectStore = "azure" - rulerStore = "azure" - compactorStore = "azure" - } - - // Ring 拓扑:单副本用 inmemory + 127.0.0.1(无需跨实例通信); - // 多副本必须切 memberlist + 0.0.0.0,否则 ring 无法收敛、写入失败。 - ringAddr := "127.0.0.1" - ringStore := "inmemory" - memberlistBlock := "" - if d.deployment.Replicas >= 2 { - ringAddr = "0.0.0.0" - ringStore = "memberlist" - ns := d.deployment.Namespace - if ns == "" { - ns = "log" - } - memberlistBlock = fmt.Sprintf(`memberlist: - join_members: - - loki-headless.%s.svc.cluster.local - bind_port: 7946 - dead_node_reclaim_time: 30s - gossip_to_dead_nodes_time: 2m - left_ingesters_timeout: 5m`, ns) - } - - data := lokiConfigData{ - AuthEnabled: d.deployment.AuthEnabled, - ReplicationFactor: d.replicationFactor(), - RetentionPeriod: retentionHours, - StorageConfigBlock: storageBlock, - ObjectStore: objectStore, - RulerStoreType: rulerStore, - CompactorStoreType: compactorStore, - RingInstanceAddr: ringAddr, - RingKVStore: ringStore, - MemberlistBlock: memberlistBlock, - RulerStorageBlock: d.buildRulerStorageBlock(), - } - - tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate) - if err != nil { - return d.generateLokiConfigFallback(retentionHours) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return d.generateLokiConfigFallback(retentionHours) - } - return buf.String() -} - -func (d *LokiDeployer) buildStorageConfigBlock() string { - if d.deployment.StorageConfig == nil { - return "" - } - - sc := d.deployment.StorageConfig - switch sc.StorageType { - case "s3": - endpoint := sc.Endpoint - if endpoint == "" { - endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) - } - insecure := "false" - if sc.Insecure { - insecure = "true" - } - return fmt.Sprintf(` aws: - s3: s3://%s/%s - endpoint: %s - region: %s - access_key_id: %s - secret_access_key: %s - insecure: %s - s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, insecure) - case "oss": - return fmt.Sprintf(` alibabacloud: - bucket: %s - endpoint: %s - region: %s - access_key_id: %s - secret_access_key: %s`, sc.Bucket, sc.Endpoint, sc.Region, sc.AccessKey, sc.SecretKey) - case "minio": - insecure := "false" - if sc.Insecure { - insecure = "true" - } - return fmt.Sprintf(` aws: - s3: s3://%s/%s - endpoint: %s - access_key_id: %s - secret_access_key: %s - insecure: %s - s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey, insecure) - case "gcs": - return fmt.Sprintf(` gcs: - bucket_name: %s`, sc.Bucket) - case "azure": - return fmt.Sprintf(` azure: - container_name: %s - account_name: %s - account_key: %s`, sc.Bucket, sc.AccessKey, sc.SecretKey) - default: - return "" - } -} - -// buildRulerStorageBlock 生成 ruler.storage 下与存储类型同名的嵌套配置 -// (Loki 3.x 的 ruler 存储客户端不回退读取 storage_config,缺少此块会以空 bucket 启动失败) -func (d *LokiDeployer) buildRulerStorageBlock() string { - if d.deployment.StorageConfig == nil { - return "" - } - sc := d.deployment.StorageConfig - switch sc.StorageType { - case "s3": - endpoint := sc.Endpoint - if endpoint == "" { - endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) - } - insecure := "false" - if sc.Insecure { - insecure = "true" - } - return fmt.Sprintf(` s3: - s3: s3://%s/%s - endpoint: %s - region: %s - access_key_id: %s - secret_access_key: %s - insecure: %s - s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, insecure) - case "oss": - return fmt.Sprintf(` s3: - s3: s3://%s/%s - endpoint: %s - access_key_id: %s - secret_access_key: %s - s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey) - case "minio": - insecure := "false" - if sc.Insecure { - insecure = "true" - } - return fmt.Sprintf(` s3: - s3: s3://%s/%s - endpoint: %s - access_key_id: %s - secret_access_key: %s - insecure: %s - s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey, insecure) - case "gcs": - return fmt.Sprintf(` gcs: - bucket_name: %s`, sc.Bucket) - case "azure": - return fmt.Sprintf(` azure: - container_name: %s - account_name: %s - account_key: %s`, sc.Bucket, sc.AccessKey, sc.SecretKey) - default: - return "" - } -} - -// generateLokiConfigFallback 当模板解析失败时的降级配置 -func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string { - return fmt.Sprintf(`auth_enabled: %v -server: - http_listen_port: 3100 - grpc_listen_port: 9095 - http_server_read_timeout: 3m - http_server_write_timeout: 3m - log_level: info -common: - path_prefix: /var/lib/loki - storage: - filesystem: - chunks_directory: /var/lib/loki/chunks - rules_directory: /var/lib/loki/rules - replication_factor: %d - ring: - instance_addr: 127.0.0.1 - kvstore: - store: inmemory -ingester: - wal: - enabled: true - dir: /var/lib/loki/wal - lifecycler: - ring: - kvstore: - store: inmemory - replication_factor: %d - final_sleep: 0s - chunk_idle_period: 30m - max_chunk_age: 2h -querier: - max_concurrent: 10 -compactor: - working_directory: /var/lib/loki/compactor - retention_enabled: true - retention_delete_delay: 2h -limits_config: - retention_period: %dh - ingestion_rate_mb: 16 - ingestion_burst_size_mb: 32 -schema_config: - configs: - - from: "2024-01-01" - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: tsdb_index_ - period: 24h -analytics: - reporting_enabled: false -`, d.deployment.AuthEnabled, d.replicationFactor(), d.replicationFactor(), retentionHours) -} - -// ==================== 辅助函数 ==================== - func (d *LokiDeployer) effectiveVersion() string { if d.deployment.Version != "" { return d.deployment.Version @@ -1090,13 +185,6 @@ func (d *LokiDeployer) effectiveNamespace() string { return "log" } -func (d *LokiDeployer) effectiveReleaseName() string { - if d.deployment.Name != "" { - return d.deployment.Name - } - return "loki" -} - func (d *LokiDeployer) effectiveStorageType() string { if d.deployment.StorageConfig != nil && d.deployment.StorageConfig.StorageType != "" { return d.deployment.StorageConfig.StorageType @@ -1129,73 +217,16 @@ func (d *LokiDeployer) log(level, message, detail string) { // ==================== 清理 / 卸载 ==================== func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { - // 同环境部署的 Grafana 一并清理 - if deployment.GrafanaEnabled { - CleanupGrafana(ctx, deployment) - } - - if deployment.Mode == "k8s-helm" && deployment.Cluster != nil { - kubeconfig, err := resolveKubeconfig(deployment.Cluster) - if err != nil { - return - } - kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) - if err != nil { - return - } - defer cleanup() - - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) - ns := deployment.Namespace - if ns == "" { - ns = "log" - } - name := deployment.Name - if name == "" { - name = "loki" - } - - cmd := exec.CommandContext(ctx, "helm", "uninstall", name, "--namespace", ns) - cmd.Env = env - cmd.CombinedOutput() - - // 清理 PVC(Helm 不会自动删除 PVC) - cleanupPVC := exec.CommandContext(ctx, "kubectl", "delete", "pvc", - "-l", "app.kubernetes.io/name=loki", "-n", ns, "--ignore-not-found=true") - cleanupPVC.Env = env - cleanupPVC.CombinedOutput() - } - if deployment.Mode == "k8s-yaml" && deployment.Cluster != nil { - kubeconfig, err := resolveKubeconfig(deployment.Cluster) - if err != nil { - return - } - kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) - if err != nil { - return - } - defer cleanup() - - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) ns := deployment.Namespace if ns == "" { ns = "log" } - - // 删除所有相关资源 - resources := []string{ - "statefulset/loki", - "service/loki", - "configmap/loki-config", - "persistentvolumeclaim/loki-data", - } - for _, res := range resources { - cmd := exec.CommandContext(ctx, "kubectl", "delete", res, "-n", ns, "--ignore-not-found=true") - cmd.Env = env - cmd.CombinedOutput() + for _, item := range [][2]string{{"statefulset", "loki"}, {"service", "loki"}, {"configmap", "loki-config"}, {"persistentvolumeclaim", "loki-data"}} { + _ = deleteK8sResource(ctx, deployment.Cluster, item[0], ns, item[1]) } } + // 历史 Helm release 不由此版本自动删除,避免误删旧数据。 if deployment.Mode == "host-binary" && deployment.Host != nil { host := deployment.Host @@ -1208,6 +239,9 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { client.Execute(ctx, "systemctl daemon-reload") } } + if deployment.Mode == "host-docker" && deployment.Host != nil { + _ = stopDockerLoki(ctx, deployment) + } } // DecryptCred 解密凭证 diff --git a/backend/services/loki_docker.go b/backend/services/loki_docker.go new file mode 100644 index 0000000..c2cd1c6 --- /dev/null +++ b/backend/services/loki_docker.go @@ -0,0 +1,149 @@ +package services + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "opsxloki/models" +) + +func dockerContainerName(id uint) string { return fmt.Sprintf("lokideploy-loki-%d", id) } +func dockerConfigDir(id uint) string { return fmt.Sprintf("/etc/lokideploy/loki-%d", id) } +func dockerConfigPath(id uint) string { return dockerConfigDir(id) + "/loki.yaml" } +func dockerDataDir(id uint) string { return fmt.Sprintf("/var/lib/lokideploy/loki-%d", id) } + +func dockerLokiImage(version string) (string, error) { + if version == "" { + version = "3.0.0" + } + for _, supported := range LokiVersionList { + if supported == version && strings.HasPrefix(version, "3.") { + return "grafana/loki:" + version, nil + } + } + return "", fmt.Errorf("主机 Docker 部署请选择支持的 Loki 3.x 版本") +} + +func stopDockerLoki(ctx context.Context, deployment *models.LokiDeployment) error { + if deployment.Host == nil { + return fmt.Errorf("部署未关联主机") + } + client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host)) + if err != nil { + return fmt.Errorf("连接部署主机失败: %w", err) + } + defer client.Close() + if _, stderr, err := client.Execute(ctx, "docker stop "+dockerContainerName(deployment.ID)); err != nil { + return fmt.Errorf("停止 Loki 容器失败: %v: %s", err, strings.TrimSpace(stderr)) + } + return nil +} + +func deleteDockerLoki(ctx context.Context, deployment *models.LokiDeployment) error { + if deployment.Host == nil { + return fmt.Errorf("部署未关联主机") + } + client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host)) + if err != nil { + return fmt.Errorf("连接部署主机失败: %w", err) + } + defer client.Close() + name := dockerContainerName(deployment.ID) + if _, _, err := client.Execute(ctx, "docker info >/dev/null 2>&1"); err != nil { + return fmt.Errorf("目标主机 Docker 不可用,无法安全清理部署") + } + // 仅删除该部署的容器和配置;日志数据目录保留,以便人工恢复。 + cmd := "if docker container inspect " + name + " >/dev/null 2>&1; then docker rm -f " + name + " >/dev/null || exit 1; fi; rm -rf -- " + dockerConfigDir(deployment.ID) + if _, stderr, err := client.Execute(ctx, cmd); err != nil { + return fmt.Errorf("清理 Loki 容器与配置失败: %v: %s", err, strings.TrimSpace(stderr)) + } + return nil +} + +// uploadDockerFile 通过 SSH 写入固定路径。内容仅以 base64 传给远程 shell,避免 YAML 或凭证参与 shell 解析。 +func uploadDockerFile(ctx context.Context, client *SSHClient, path, content, mode string) error { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + cmd := fmt.Sprintf("umask 077 && printf '%%s' '%s' | base64 -d > %s && chmod %s %s", encoded, path, mode, path) + _, _, err := client.Execute(ctx, cmd) + if err != nil { + return fmt.Errorf("写入主机配置失败: %w", err) + } + return nil +} + +func (d *LokiDeployer) deployDocker(ctx context.Context) error { + deployment := d.deployment + image, err := dockerLokiImage(deployment.Version) + if err != nil { + return err + } + if deployment.Host == nil { + return fmt.Errorf("请选择目标主机") + } + client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host)) + if err != nil { + return err + } + defer client.Close() + if _, _, err := client.Execute(ctx, "docker info >/dev/null 2>&1"); err != nil { + return fmt.Errorf("目标主机 Docker 不可用,请检查安装状态及 SSH 用户权限") + } + name := dockerContainerName(deployment.ID) + configDir := dockerConfigDir(deployment.ID) + dataDir := dockerDataDir(deployment.ID) + if _, _, err := client.Execute(ctx, "mkdir -p "+configDir+" "+dataDir+" && chmod 755 "+configDir+" && chown 10001:10001 "+dataDir); err != nil { + return fmt.Errorf("创建主机持久化目录失败: %w", err) + } + config := d.generateLokiConfig() + if err := uploadDockerFile(ctx, client, dockerConfigPath(deployment.ID), config, "644"); err != nil { + return err + } + // env 文件由 Docker daemon 读取;Loki 配置只保留变量引用。 + env := "" + if sc := deployment.StorageConfig; sc != nil { + for _, value := range []string{sc.AccessKey, sc.SecretKey} { + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("存储凭证包含不支持的换行字符") + } + } + env = "LOKI_STORAGE_ACCESS_KEY=" + sc.AccessKey + "\nLOKI_STORAGE_SECRET_KEY=" + sc.SecretKey + "\n" + } + if err := uploadDockerFile(ctx, client, configDir+"/storage.env", env, "600"); err != nil { + return err + } + d.log("info", "拉取 Docker 镜像 "+image, "") + if _, stderr, err := client.Execute(ctx, "docker pull "+image); err != nil { + return fmt.Errorf("拉取 Loki 镜像失败: %v: %s", err, strings.TrimSpace(stderr)) + } + // 同一部署 ID 的重部署只替换容器,配置和日志数据目录保留。 + client.Execute(ctx, "docker rm -f "+name+" >/dev/null 2>&1 || true") + port := deployment.ListenPort + if port == 0 { + port = 3100 + } + listenIP := deployment.ListenIP + if listenIP == "" { + listenIP = deployment.Host.HostIP + } + cmd := fmt.Sprintf("docker run -d --name %s --restart unless-stopped --env-file %s/storage.env -p %s:%d:3100 -v %s:/etc/loki:ro -v %s:/var/lib/loki %s -config.file=/etc/loki/loki.yaml -config.expand-env=true", name, configDir, listenIP, port, configDir, dataDir, image) + if _, stderr, err := client.Execute(ctx, cmd); err != nil { + return fmt.Errorf("启动 Loki 容器失败: %v: %s", err, strings.TrimSpace(stderr)) + } + d.log("info", "等待 Loki 容器就绪", "") + readyCmd := fmt.Sprintf("curl -fsS --max-time 2 http://%s:%d/ready >/dev/null", listenIP, port) + for i := 0; i < 30; i++ { + if _, _, err := client.Execute(ctx, readyCmd); err == nil { + d.log("success", fmt.Sprintf("Loki 已启动:http://%s:%d", listenIP, port), "") + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("Loki 容器启动后未就绪,请在目标主机执行 docker logs %s", name) +} diff --git a/backend/services/loki_docker_test.go b/backend/services/loki_docker_test.go new file mode 100644 index 0000000..b2c7dea --- /dev/null +++ b/backend/services/loki_docker_test.go @@ -0,0 +1,41 @@ +package services + +import ( + "strings" + "testing" + + "opsxloki/models" +) + +func TestDockerLokiConfigUsesEnvironmentForStorageCredentials(t *testing.T) { + deployment := &models.LokiDeployment{ + ID: 7, Mode: "host-docker", RetentionDays: 60, Replicas: 1, + StorageConfig: &models.StorageConfig{ + StorageType: "minio", Endpoint: "http://10.10.10.88:9000", Bucket: "loki", + AccessKey: "test-access-secret", SecretKey: "test-secret-secret", + }, + } + config := NewLokiDeployer(deployment, nil).generateLokiConfig() + if strings.Contains(config, "test-access-secret") || strings.Contains(config, "test-secret-secret") { + t.Fatal("Docker Loki config embedded storage credentials") + } + for _, expected := range []string{"${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}", "retention_period: 1440h"} { + if !strings.Contains(config, expected) { + t.Errorf("config missing %q", expected) + } + } + if got := dockerContainerName(deployment.ID); got != "lokideploy-loki-7" { + t.Fatalf("unexpected container name: %s", got) + } +} + +func TestDockerLokiImageRejectsShellInputAndOldVersions(t *testing.T) { + for _, version := range []string{"2.9.8", "3.0.0;touch /tmp/example", "3.9.9"} { + if _, err := dockerLokiImage(version); err == nil { + t.Errorf("accepted unsupported version %q", version) + } + } + if image, err := dockerLokiImage("3.4.0"); err != nil || image != "grafana/loki:3.4.0" { + t.Fatalf("unexpected image %q, error %v", image, err) + } +} diff --git a/backend/services/loki_manifest.go b/backend/services/loki_manifest.go new file mode 100644 index 0000000..9e0cba1 --- /dev/null +++ b/backend/services/loki_manifest.go @@ -0,0 +1,243 @@ +// LokiDeployer — k8s-yaml 模式 manifest 生成(StatefulSet/PVC/ConfigMap/Service)。 +package services + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" +) + +func (d *LokiDeployer) generateManifest(ns string) string { + version := d.effectiveVersion() + image := ResolveImage("docker.io/grafana/loki:" + version) + replicas := d.deployment.Replicas + if replicas < 1 { + replicas = 1 + } + storageSize := d.deployment.StorageSize + if storageSize == "" { + storageSize = "10Gi" + } + + cpu := d.deployment.ResourcesCPU + mem := d.deployment.ResourcesMem + if cpu == "" { + cpu = "1000m" + } + if mem == "" { + mem = "1Gi" + } + + configData := d.generateLokiConfig() + secretManifest := "" + configExpandArg := "" + storageEnvBlock := "" + if sc := d.deployment.StorageConfig; sc != nil { + switch sc.StorageType { + case "s3", "oss", "minio", "azure": + secretManifest = fmt.Sprintf(`--- +apiVersion: v1 +kind: Secret +metadata: + name: loki-storage-credentials + namespace: %s +type: Opaque +data: + accessKey: %s + secretKey: %s`, ns, + base64.StdEncoding.EncodeToString([]byte(sc.AccessKey)), + base64.StdEncoding.EncodeToString([]byte(sc.SecretKey))) + configExpandArg = "\n - -config.expand-env=true" + storageEnvBlock = ` + env: + - name: LOKI_STORAGE_ACCESS_KEY + valueFrom: + secretKeyRef: + name: loki-storage-credentials + key: accessKey + - name: LOKI_STORAGE_SECRET_KEY + valueFrom: + secretKeyRef: + name: loki-storage-credentials + key: secretKey` + } + } + // ConfigMap/Secret 更新本身不会重建 Pod。把内容摘要写入 Pod 模板以触发滚动更新。 + configHash := sha256.Sum256([]byte(configData + "\n" + secretManifest)) + + manifest := fmt.Sprintf(`apiVersion: v1 +kind: Namespace +metadata: + name: %s +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: loki-config + namespace: %s +data: + loki.yaml: | +%s +%s +--- +apiVersion: v1 +kind: Service +metadata: + name: loki + namespace: %s + labels: + app: loki +spec: + type: ClusterIP + ports: + - name: http + port: 3100 + targetPort: 3100 + protocol: TCP + - name: grpc + port: 9095 + targetPort: 9095 + protocol: TCP + selector: + app: loki +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: loki-data + namespace: %s +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: %s +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: loki + namespace: %s + labels: + app: loki +spec: + serviceName: loki + replicas: %d + selector: + matchLabels: + app: loki + template: + metadata: + labels: + app: loki + annotations: + checksum/loki-config: "%x" + spec: + securityContext: + runAsUser: 0 + runAsGroup: 0 + fsGroup: 0 + containers: + - name: loki + image: grafana/loki:%s + imagePullPolicy: IfNotPresent + args: + - -config.file=/etc/loki/loki.yaml%s%s + ports: + - name: http + containerPort: 3100 + protocol: TCP + - name: grpc + containerPort: 9095 + protocol: TCP + resources: + limits: + cpu: "%s" + memory: "%s" + requests: + cpu: "200m" + memory: "256Mi" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + volumeMounts: + - name: config + mountPath: /etc/loki + readOnly: true + - name: data + mountPath: /var/lib/loki + - name: wal + mountPath: /var/lib/loki/wal + volumes: + - name: config + configMap: + name: loki-config + - name: data + persistentVolumeClaim: + claimName: loki-data + - name: wal + emptyDir: {} +`, ns, ns, indentYAML(configData, 4), secretManifest, ns, ns, storageSize, ns, replicas, configHash, image, configExpandArg, storageEnvBlock, cpu, mem) + + // 多副本:追加 Headless Service(clusterIP: None + selector app:loki), + // 使 memberlist 的 join_members (loki-headless..svc.cluster.local) 能解析到所有 Pod IP。 + if replicas >= 2 { + manifest += fmt.Sprintf(` +--- +apiVersion: v1 +kind: Service +metadata: + name: loki-headless + namespace: %s + labels: + app: loki +spec: + clusterIP: None + ports: + - name: http + port: 3100 + targetPort: 3100 + protocol: TCP + - name: grpc + port: 9095 + targetPort: 9095 + protocol: TCP + - name: memberlist + port: 7946 + targetPort: 7946 + protocol: TCP + selector: + app: loki +`, ns) + } + return manifest +} + +// ==================== Loki 本地配置生成 (text/template) ==================== diff --git a/backend/services/notifier.go b/backend/services/notifier.go new file mode 100644 index 0000000..38278d4 --- /dev/null +++ b/backend/services/notifier.go @@ -0,0 +1,146 @@ +package services + +// 飞书/Lark 群机器人通知 — 卡片消息(参照 opsx-sec notifier 与运维告警脚本的卡片结构), +// webhook 存 SystemSetting(AES 加密),对外只回 hasWebhook。 +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" +) + +const ( + notifySettingKey = "notify_settings" + notifyHTTPTimeout = 10 * time.Second +) + +// NotifyConfig 通知配置(webhook/签名 加密存储) +type NotifyConfig struct { + Enabled bool `json:"enabled"` + Webhook string `json:"webhook"` // ENC: 密文存储 + SigningSecret string `json:"signingSecret"` // 飞书签名校验(可选) +} + +// NotifyPublicConfig 对外输出形态 +type NotifyPublicConfig struct { + Enabled bool `json:"enabled"` + HasWebhook bool `json:"hasWebhook"` + HasSecret bool `json:"hasSecret"` +} + +func GetNotifyConfig() *NotifyConfig { + db := repository.GetDB() + db.RLock() + var raw string + for i := range db.Settings { + if db.Settings[i].Key == notifySettingKey { + raw = db.Settings[i].Value + break + } + } + db.RUnlock() + cfg := &NotifyConfig{} + if raw != "" { + _ = json.Unmarshal([]byte(raw), cfg) + } + if c, err := DecryptCred(cfg.Webhook); err == nil { + cfg.Webhook = c + } + if c, err := DecryptCred(cfg.SigningSecret); err == nil { + cfg.SigningSecret = c + } + return cfg +} + +func SaveNotifyConfig(enabled bool, webhook, secret string) error { + // 留空 = 保留旧值 + old := GetNotifyConfig() + if webhook == "" { + webhook = old.Webhook + } else if enc, err := crypto.EncryptField(webhook); err == nil { + webhook = enc + } + if secret == "" { + secret = old.SigningSecret + } else if enc, err := crypto.EncryptField(secret); err == nil { + secret = enc + } + data, _ := json.Marshal(&NotifyConfig{Enabled: enabled, Webhook: webhook, SigningSecret: secret}) + + db := repository.GetDB() + newID := db.NextID("settings") + db.Lock() + defer db.Unlock() + for i := range db.Settings { + if db.Settings[i].Key == notifySettingKey { + db.Settings[i].Value = string(data) + db.RequestSave() + return nil + } + } + db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: notifySettingKey, Value: string(data)}) + db.RequestSave() + return nil +} + +// larkSign 飞书签名:timestamp + "\n" + secret 的 HMAC-SHA256,base64 +func larkSign(secret string, ts int64) string { + stringToSign := fmt.Sprintf("%d\n%s", ts, secret) + h := hmac.New(sha256.New, []byte(stringToSign)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// SendLarkCard 发送飞书卡片;title 带颜色(red/orange),lines 为 markdown 内容行 +func SendLarkCard(title, color string, lines []string) error { + cfg := GetNotifyConfig() + if !cfg.Enabled || cfg.Webhook == "" { + return fmt.Errorf("通知未启用或未配置 webhook") + } + content := strings.Join(lines, "\n") + payload := map[string]interface{}{ + "msg_type": "interactive", + "card": map[string]interface{}{ + "config": map[string]interface{}{"wide_screen_mode": true}, + "header": map[string]interface{}{ + "title": map[string]string{"tag": "plain_text", "content": title}, + "template": color, + }, + "elements": []interface{}{ + map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}}, + map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "opsx-loki 日志告警"}}}, + }, + }, + } + if cfg.SigningSecret != "" { + ts := time.Now().Unix() + payload["timestamp"] = fmt.Sprintf("%d", ts) + payload["sign"] = larkSign(cfg.SigningSecret, ts) + } + body, _ := json.Marshal(payload) + resp, err := (&http.Client{Timeout: notifyHTTPTimeout}).Post(cfg.Webhook, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("飞书返回 HTTP %d", resp.StatusCode) + } + var out struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + if out.Code != 0 { + return fmt.Errorf("飞书拒绝: %s", out.Msg) + } + return nil +} diff --git a/backend/services/notify_channel.go b/backend/services/notify_channel.go new file mode 100644 index 0000000..4a37afc --- /dev/null +++ b/backend/services/notify_channel.go @@ -0,0 +1,226 @@ +package services + +// 多通知通道(飞书/Lark 群机器人)CRUD 与按通道发送。 +// 取代旧的单 webhook 全局配置:每个告警群一个通道,告警规则各自关联。 +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" +) + +const channelHTTPTimeout = 10 * time.Second + +func ListNotifyChannels() []map[string]interface{} { + db := repository.GetDB() + db.RLock() + out := make([]map[string]interface{}, 0, len(db.NotifyChannels)) + for i := range db.NotifyChannels { + out = append(out, db.NotifyChannels[i].Sanitized()) + } + db.RUnlock() + return out +} + +func GetNotifyChannel(id uint) *models.NotifyChannel { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + for i := range db.NotifyChannels { + if db.NotifyChannels[i].ID == id { + c := db.NotifyChannels[i] + return &c + } + } + return nil +} + +// GetNotifyChannelDecrypted 取通道并解密凭证(发送用) +func GetNotifyChannelDecrypted(id uint) *models.NotifyChannel { + c := GetNotifyChannel(id) + if c == nil { + return nil + } + if w, err := DecryptCred(c.Webhook); err == nil { + c.Webhook = w + } + if s, err := DecryptCred(c.SigningSecret); err == nil { + c.SigningSecret = s + } + return c +} + +// SaveNotifyChannel 新建(id=0)或更新;webhook/secret 非空才更新(留空=保留旧值) +func SaveNotifyChannel(id uint, name, webhook, secret string, enabled bool) (*models.NotifyChannel, error) { + db := repository.GetDB() + // 更新:留空字段回填旧值(旧值已是密文) + if id > 0 { + db.RLock() + var old models.NotifyChannel + found := false + for i := range db.NotifyChannels { + if db.NotifyChannels[i].ID == id { + old = db.NotifyChannels[i] + found = true + break + } + } + db.RUnlock() + if !found { + return nil, fmt.Errorf("通道不存在") + } + if webhook == "" { + webhook = old.Webhook + } else if enc, err := crypto.EncryptField(webhook); err == nil { + webhook = enc + } + if secret == "" { + secret = old.SigningSecret + } else if enc, err := crypto.EncryptField(secret); err == nil { + secret = enc + } + db.Lock() + defer db.Unlock() + for i := range db.NotifyChannels { + if db.NotifyChannels[i].ID == id { + db.NotifyChannels[i].Name = name + db.NotifyChannels[i].Webhook = webhook + db.NotifyChannels[i].SigningSecret = secret + db.NotifyChannels[i].Enabled = enabled + db.NotifyChannels[i].UpdatedAt = time.Now() + db.RequestSave() + c := db.NotifyChannels[i] + return &c, nil + } + } + return nil, fmt.Errorf("通道不存在") + } + + // 新建 + if name == "" { + return nil, fmt.Errorf("通道名称不能为空") + } + if webhook == "" { + return nil, fmt.Errorf("webhook 不能为空") + } + if enc, err := crypto.EncryptField(webhook); err == nil { + webhook = enc + } + if secret != "" { + if enc, err := crypto.EncryptField(secret); err == nil { + secret = enc + } + } + newID := db.NextID("notifyChannels") + ch := &models.NotifyChannel{ + ID: newID, Name: name, Webhook: webhook, SigningSecret: secret, Enabled: enabled, + } + db.Lock() + defer db.Unlock() + db.NotifyChannels = append(db.NotifyChannels, *ch) + db.RequestSave() + return ch, nil +} + +func DeleteNotifyChannel(id uint) error { + // 有关联规则时拒绝删除 + db := repository.GetDB() + db.RLock() + for i := range db.AlertRules { + for _, chID := range db.AlertRules[i].ChannelIDs { + if chID == id { + db.RUnlock() + return fmt.Errorf("仍有告警规则关联此通道,请先解除关联") + } + } + } + db.RUnlock() + db.Lock() + defer db.Unlock() + for i := range db.NotifyChannels { + if db.NotifyChannels[i].ID == id { + db.NotifyChannels = append(db.NotifyChannels[:i], db.NotifyChannels[i+1:]...) + db.RequestSave() + return nil + } + } + return fmt.Errorf("通道不存在") +} + +// larkSignChannel 飞书签名 +func larkSignChannel(secret string, ts int64) string { + stringToSign := fmt.Sprintf("%d\n%s", ts, secret) + h := hmac.New(sha256.New, []byte(stringToSign)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// SendLarkCardToChannel 向指定通道发送卡片 +func SendLarkCardToChannel(channelID uint, title, color string, lines []string) error { + ch := GetNotifyChannelDecrypted(channelID) + if ch == nil { + return fmt.Errorf("通知通道不存在") + } + if !ch.Enabled { + return fmt.Errorf("通道「%s」已禁用", ch.Name) + } + content := joinNonEmpty(lines, "\n") + payload := map[string]interface{}{ + "msg_type": "interactive", + "card": map[string]interface{}{ + "config": map[string]interface{}{"wide_screen_mode": true}, + "header": map[string]interface{}{ + "title": map[string]string{"tag": "plain_text", "content": title}, + "template": color, + }, + "elements": []interface{}{ + map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}}, + map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "opsx-loki 日志告警"}}}, + }, + }, + } + if ch.SigningSecret != "" { + ts := time.Now().Unix() + payload["timestamp"] = fmt.Sprintf("%d", ts) + payload["sign"] = larkSignChannel(ch.SigningSecret, ts) + } + body, _ := json.Marshal(payload) + resp, err := (&http.Client{Timeout: channelHTTPTimeout}).Post(ch.Webhook, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("飞书返回 HTTP %d", resp.StatusCode) + } + var out struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + if out.Code != 0 { + return fmt.Errorf("飞书拒绝: %s", out.Msg) + } + return nil +} + +func joinNonEmpty(items []string, sep string) string { + result := "" + for _, item := range items { + if item == "" { + continue + } + if result != "" { + result += sep + } + result += item + } + return result +} diff --git a/backend/services/storage.go b/backend/services/storage.go index 8c3045f..bcac2d7 100644 --- a/backend/services/storage.go +++ b/backend/services/storage.go @@ -108,6 +108,27 @@ func (s *StorageService) Update(id uint, updates map[string]interface{}) error { if v, ok := updates["region"].(string); ok { db.Storages[i].Region = v } + if v, ok := updates["pathPrefix"].(string); ok { + db.Storages[i].PathPrefix = v + } + if v, ok := updates["insecure"].(bool); ok { + db.Storages[i].Insecure = v + } + // 凭证编辑约定:传入非空才更新(留空=保留旧密文) + if v, ok := updates["accessKey"].(string); ok && v != "" { + enc, err := crypto.EncryptField(v) + if err != nil { + return fmt.Errorf("AccessKey 加密失败: %w", err) + } + db.Storages[i].AccessKey = enc + } + if v, ok := updates["secretKey"].(string); ok && v != "" { + enc, err := crypto.EncryptField(v) + if err != nil { + return fmt.Errorf("SecretKey 加密失败: %w", err) + } + db.Storages[i].SecretKey = enc + } if v, ok := updates["isDefault"].(bool); ok { if v { for j := range db.Storages { diff --git a/backend/services/storage_check.go b/backend/services/storage_check.go index 8d2a050..febbfd3 100644 --- a/backend/services/storage_check.go +++ b/backend/services/storage_check.go @@ -24,13 +24,13 @@ type StorageTestResult struct { // StorageTestParams 测试参数(与创建存储配置一致,额外支持自动建桶) type StorageTestParams struct { - StorageType string - Endpoint string - Bucket string - Region string - AccessKey string - SecretKey string - Insecure bool + StorageType string + Endpoint string + Bucket string + Region string + AccessKey string + SecretKey string + Insecure bool CreateIfMissing bool } diff --git a/backend/services/storage_update_test.go b/backend/services/storage_update_test.go new file mode 100644 index 0000000..6ce8d44 --- /dev/null +++ b/backend/services/storage_update_test.go @@ -0,0 +1,34 @@ +package services + +import ( + "testing" + + "opsxloki/repository" +) + +func TestStorageUpdateReplacesBothCredentials(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters") + t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters") + if err := repository.InitDB(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = repository.Shutdown() }) + svc := NewStorageService() + storage, err := svc.Create("test", "minio", "http://localhost:9000", "loki", "", "old-access", "old-secret", "", true, true) + if err != nil { + t.Fatal(err) + } + if err := svc.Update(storage.ID, map[string]interface{}{"accessKey": "new-access", "secretKey": "new-secret"}); err != nil { + t.Fatal(err) + } + updated, err := svc.Get(storage.ID) + if err != nil { + t.Fatal(err) + } + access, accessErr := DecryptCred(updated.AccessKey) + secret, secretErr := DecryptCred(updated.SecretKey) + if accessErr != nil || secretErr != nil || access != "new-access" || secret != "new-secret" { + t.Fatal("storage credentials were not both updated and decryptable") + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index 1f5d23f..cccb717 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,5 +1,5 @@ # Logs -logs +/logs/ *.log npm-debug.log* yarn-debug.log* diff --git a/frontend/src/api/alerts/index.ts b/frontend/src/api/alerts/index.ts new file mode 100644 index 0000000..9acf63c --- /dev/null +++ b/frontend/src/api/alerts/index.ts @@ -0,0 +1,66 @@ +// 告警域 API — 飞书通知配置 + 告警规则 +import { http } from '../http' + +export interface NotifyConfig { + enabled: boolean + hasWebhook: boolean + hasSecret: boolean +} + +export interface AlertRule { + id: number + name: string + sourceId: number + channelIds: number[] + query: string + windowMin: number + threshold: number + enabled: boolean + cooldownMin: number + lastFiredAt?: string + createdAt?: string +} + +export interface AlertDelivery { + id: number + ruleId: number + ruleName: string + count: number + status: 'sent' | 'failed' | 'cooldown' + reason: string + createdAt: string +} + +export const alertsApi = { + getNotifyConfig: () => http.get('/alerts/notify-config'), + saveNotifyConfig: (data: { enabled: boolean; webhook?: string; signingSecret?: string }) => + http.put<{ message: string }>('/alerts/notify-config', data), + testNotifyConfig: () => http.post<{ status: string }>('/alerts/notify-config/test'), + + listRules: () => http.get('/alerts/rules'), + createRule: (data: Omit) => + http.post('/alerts/rules', data), + updateRule: (id: number, data: Partial) => + http.put(`/alerts/rules/${id}`, data), + deleteRule: (id: number) => http.delete<{ message: string }>(`/alerts/rules/${id}`), + checkNow: () => http.post<{ message: string }>('/alerts/check'), + deliveries: () => http.get('/alerts/deliveries'), + // 通知通道(多 webhook 对应不同告警群) + listChannels: () => http.get('/alerts/channels'), + saveChannel: (data: { id?: number; name: string; webhook?: string; signingSecret?: string; enabled: boolean }) => + http.post('/alerts/channels', data), + updateChannel: (id: number, data: { name: string; webhook?: string; signingSecret?: string; enabled: boolean }) => + http.put(`/alerts/channels/${id}`, data), + deleteChannel: (id: number) => http.delete<{ message: string }>(`/alerts/channels/${id}`), + testChannel: (data: { id?: number; name?: string; webhook?: string; signingSecret?: string }) => + http.post<{ status: string }>('/alerts/channels/test', data), +} + +export interface NotifyChannel { + id: number + name: string + hasWebhook: boolean + hasSecret: boolean + enabled: boolean + createdAt?: string +} diff --git a/frontend/src/api/cluster/index.ts b/frontend/src/api/cluster/index.ts index af57b51..a0d9421 100644 --- a/frontend/src/api/cluster/index.ts +++ b/frontend/src/api/cluster/index.ts @@ -14,6 +14,7 @@ export interface ClusterCreateInput { export type ClusterUpdateInput = Partial; + export const clusterApi = { list: (params: ListParams = {}, options?: RequestOptions) => http.get>(`/clusters${buildQuery(params)}`, options), @@ -31,4 +32,13 @@ export const clusterApi = { http.post<{ message: string }>(`/clusters/${id}/validate`), getInfo: (id: number) => http.get(`/clusters/${id}/info`), + + + namespaces: (id: number, options?: RequestOptions) => + http.get<{ namespaces: string[] }>(`/clusters/${id}/namespaces`, options), + + deployments: (id: number, namespace: string, options?: RequestOptions) => + http.get<{ deployments: Array<{ name: string; replicas: number; readyReplicas: number }> }>( + `/clusters/${id}/namespaces/${encodeURIComponent(namespace)}/deployments`, options + ), }; diff --git a/frontend/src/api/collector/index.ts b/frontend/src/api/collector/index.ts index 1abab56..86d743f 100644 --- a/frontend/src/api/collector/index.ts +++ b/frontend/src/api/collector/index.ts @@ -10,6 +10,9 @@ export const collectorApi = { create: (data: CreateCollectorRequest) => http.post('/collectors', data), + update: (id: number, data: { name: string; logPath: string }) => + http.put<{ message: string }>(`/collectors/${id}`, data), + remove: (id: number) => http.delete<{ message: string }>(`/collectors/${id}`), install: (id: number) => http.post<{ message: string }>(`/collectors/${id}/install`), diff --git a/frontend/src/api/deployment/index.ts b/frontend/src/api/deployment/index.ts index 3c9f75c..12a516a 100644 --- a/frontend/src/api/deployment/index.ts +++ b/frontend/src/api/deployment/index.ts @@ -21,6 +21,12 @@ export const deploymentApi = { update: (id: number, data: DeploymentUpdateInput) => http.put<{ message: string }>(`/deployments/${id}`, data), + updateRetention: (id: number, yaml: string, apply: boolean) => + http.post<{ retentionDays: number; applying: boolean }>(`/deployments/${id}/retention`, { yaml, apply }), + + liveConfig: (id: number) => + http.get<{ location: string; yaml: string }>(`/deployments/${id}/config`), + remove: (id: number) => http.delete<{ message: string }>(`/deployments/${id}`), deploy: (id: number) => http.post<{ message: string }>(`/deployments/${id}/deploy`), diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1cde07e..8d427d1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -8,3 +8,5 @@ export * from './storage'; export * from './deployment'; export * from './collector'; export * from './user'; +export * from './alerts'; +export * from './settings'; diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts new file mode 100644 index 0000000..7379a36 --- /dev/null +++ b/frontend/src/api/logs/index.ts @@ -0,0 +1,32 @@ +import { http, buildQuery } from '../http' +import type { PageData } from '@/types' + +export interface LogSourceAccess { userId: number; labels: Record } +export interface LogPolicyGroup { id: number; name: string; sourceId: number; labels: Record; userIds: number[]; createdAt?: string; updatedAt?: string } +export interface LogSource { + id: number; name: string; targetType: 'url' | 'cluster-svc'; clusterID?: number + namespace: string; service: string; port: number; url: string + authType: string; basicUser: string; hasBasicPass?: boolean + headers?: Record; headerNames?: string[]; skipTLSVerify?: boolean; hasBearerToken?: boolean + isDefault: boolean; managed?: boolean; access?: LogSourceAccess[] +} +export interface LogEntry { timestamp: string; timestampNs: string; level: string; message: string; labels: Record } +export interface HistogramBucket { timestamp: string; count: number } +export interface LogResult { entries: LogEntry[]; hasMore: boolean; nextCursor: string } +export interface LogQuery { sourceId: number; query: string; start: number; end: number; limit?: number; direction?: 'backward' | 'forward'; cursor?: string } +export const logsApi = { + sources: () => http.get>('/logs/sources'), + createSource: (source: Partial & { basicPass?: string; bearerToken?: string }) => http.post('/logs/sources', source), + updateSource: (id: number, data: Partial) => http.put(`/logs/sources/${id}`, data), + deleteSource: (id: number) => http.delete(`/logs/sources/${id}`), + testSource: (data: Partial) => http.post<{ success: boolean; message: string }>('/logs/sources/test', data), + query: (data: LogQuery) => http.post('/logs/query', data, { timeout: 125000 }), + context: (data: { sourceId: number; timestampNs: string; labels: Record }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data), + histogram: (data: LogQuery) => http.post>>('/logs/histogram', data, { timeout: 125000 }), + labels: (sourceId: number, start?: number, end?: number) => http.get(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`), + labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`), + policyGroups: () => http.get('/logs/policy-groups'), + createPolicyGroup: (data: Omit) => http.post('/logs/policy-groups', data), + updatePolicyGroup: (id: number, data: Omit) => http.put(`/logs/policy-groups/${id}`, data), + deletePolicyGroup: (id: number) => http.delete(`/logs/policy-groups/${id}`), +} diff --git a/frontend/src/api/settings/index.ts b/frontend/src/api/settings/index.ts new file mode 100644 index 0000000..7d05f9f --- /dev/null +++ b/frontend/src/api/settings/index.ts @@ -0,0 +1,17 @@ +// 设置域 API — 镜像源等平台级配置 +import { http } from '../http' + +export interface ImageRegistryConfig { + mode: 'overseas' | 'cn' | 'custom' + customHost: string + sampleLoki: string + sampleGrafana: string + samplePromtail: string + sampleFluentBit: string +} + +export const settingsApi = { + getImageRegistry: () => http.get('/settings/image-registry'), + saveImageRegistry: (data: { mode: string; customHost?: string }) => + http.put('/settings/image-registry', data), +} diff --git a/frontend/src/api/storage/index.ts b/frontend/src/api/storage/index.ts index d9ec422..7b1d82c 100644 --- a/frontend/src/api/storage/index.ts +++ b/frontend/src/api/storage/index.ts @@ -13,6 +13,7 @@ export interface StorageCreateInput { pathPrefix?: string; insecure?: boolean; isDefault?: boolean; + hasSecretKey?: boolean; } export type StorageUpdateInput = Partial; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 74c4bf2..de59d2f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -46,10 +46,10 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate? onClick={onNavigate} aria-label={item.label} className={cn( - 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all', + 'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors', isActive - ? 'bg-primary text-primary-foreground' - : 'text-slate-400 hover:bg-slate-800 hover:text-white' + ? 'bg-sidebar-primary/15 text-sidebar-primary font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-primary' + : 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground' )} title={!collapsed ? item.label : undefined} > @@ -68,15 +68,15 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate? // 侧栏 Logo 区 function SidebarLogo({ collapsed }: { collapsed: boolean }) { return ( -
+
{collapsed ? ( -
- +
+
) : (
-
- +
+
opsx-loki
@@ -162,18 +162,18 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode }) {!isMobile && (