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 && (