diff --git a/.dockerignore b/.dockerignore index 3f9b6c3..860bcae 100644 --- a/.dockerignore +++ b/.dockerignore @@ -45,3 +45,16 @@ secrets/ # Build artifacts data/ logs/ +opsxloki.json +*.db +*.db-journal +*.sqlite +*.sqlite3 +*.bak +*.tmp + +# Local deployment manifests / dev compose (not part of image) +kubernetes/ +docker-compose*.yml +.kube/ +.ssh/ diff --git a/backend/controllers/image_registry.go b/backend/controllers/image_registry.go index 85a17a8..35d9b66 100644 --- a/backend/controllers/image_registry.go +++ b/backend/controllers/image_registry.go @@ -22,7 +22,7 @@ func (ctrl *ImageRegistryController) Save(c *gin.Context) { if !utils.BindAndValidate(c, &req) { return } - if err := services.SaveImageRegistry(req.Mode, req.CustomHost); err != nil { + if err := services.SaveImageRegistry(req.Mode, req.CustomHost, req.Images); err != nil { utils.Error(c, 400, err.Error()) return } diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 6b54e1f..0e91c4e 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -63,6 +63,9 @@ func NewLogsController() *LogsController { // ==================== 数据源 ==================== func (ctrl *LogsController) ListSources(c *gin.Context) { + if c.GetString("role") == "admin" { + services.EnsureLogSourcesForRunningDeployments() + } list := services.ListVisibleLogSources(c.GetUint("userId"), c.GetString("role") == "admin") utils.Success(c, utils.PageData{List: list, Total: int64(len(list)), Page: 1, PageSize: 100}) } diff --git a/backend/dto/image_registry.go b/backend/dto/image_registry.go index effd5ae..82116f7 100644 --- a/backend/dto/image_registry.go +++ b/backend/dto/image_registry.go @@ -1,6 +1,9 @@ package dto +import "opsxloki/services" + type ImageRegistryRequest struct { - Mode string `json:"mode" binding:"required,oneof=overseas cn custom"` - CustomHost string `json:"customHost"` + Mode string `json:"mode" binding:"required,oneof=overseas cn custom"` + CustomHost string `json:"customHost"` + Images map[string]services.ComponentImage `json:"images"` } diff --git a/backend/dto/storage.go b/backend/dto/storage.go index 4411082..9e629ed 100644 --- a/backend/dto/storage.go +++ b/backend/dto/storage.go @@ -3,7 +3,7 @@ package dto // 存储域 type CreateStorageRequest struct { Name string `json:"name" binding:"required"` - StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"` + StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs cos obs"` Endpoint string `json:"endpoint"` Bucket string `json:"bucket" binding:"required"` Region string `json:"region"` @@ -16,7 +16,7 @@ type CreateStorageRequest struct { // StorageTestRequest 测试连接(不落库;createIfMissing 时桶不存在则自动创建) type StorageTestRequest struct { - StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"` + StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs cos obs"` Endpoint string `json:"endpoint" binding:"required"` Bucket string `json:"bucket" binding:"required"` Region string `json:"region"` diff --git a/backend/main.go b/backend/main.go index 6695cc1..318084f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -27,6 +27,9 @@ func main() { log.Println("Seeding default data...") seed.Seed() + // 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件 + services.MigrateClusterKubeconfigs() + // 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine, // 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。 repository.GetDB().RecoverOrphanDeployments() diff --git a/backend/repository/db.go b/backend/repository/db.go index 0a08906..ca95cc0 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -53,7 +53,7 @@ func GetDB() *JsonDB { func InitDB() error { cfg := config.GetConfig() - _ = os.MkdirAll(cfg.DataDir, 0755) + _ = os.MkdirAll(cfg.DataDir, 0700) d := &JsonDB{ dataFile: cfg.DatabasePath, @@ -258,7 +258,7 @@ func (d *JsonDB) doSave() error { dir := filepath.Dir(d.dataFile) tmpFile := filepath.Join(dir, fmt.Sprintf(".opsxloki-%d.tmp", time.Now().UnixNano())) - if err := os.WriteFile(tmpFile, data, 0644); err != nil { + if err := os.WriteFile(tmpFile, data, 0600); err != nil { return err } diff --git a/backend/services/cluster.go b/backend/services/cluster.go index 23e54ae..91e4a0c 100644 --- a/backend/services/cluster.go +++ b/backend/services/cluster.go @@ -6,6 +6,7 @@ import ( "opsxloki/pkg/crypto" "os" "path/filepath" + "strings" "time" "opsxloki/config" @@ -32,6 +33,39 @@ func SaveKubeConfig(name, content string) (string, error) { return path, nil } +// MigrateClusterKubeconfigs 启动时把历史明文 kubeconfig 加密回 DB,并删除明文文件。 +// 幂等:已加密的直接跳过。 +func MigrateClusterKubeconfigs() { + db := repository.GetDB() + if db == nil { + return + } + kubeDir := filepath.Clean(config.GetConfig().KubeDir) + db.Lock() + changed := false + for i := range db.Clusters { + c := &db.Clusters[i] + if c.KubeConfig == "" || c.IsKubeconfigEncrypted() { + continue + } + if err := c.EncryptAndSetKubeconfig(c.KubeConfig); err != nil { + continue + } + if c.KubeConfigPath != "" { + p := filepath.Clean(c.KubeConfigPath) + if strings.HasPrefix(p, kubeDir+string(os.PathSeparator)) { + _ = os.Remove(p) + } + c.KubeConfigPath = "" + } + changed = true + } + db.Unlock() + if changed { + _ = db.RequestSaveSync() + } +} + // Create 创建集群。token 接入时自动构造等价 kubeconfig,统一存储,使部署模块同样可用。 func (s *ClusterService) Create(name, description, configType, apiServer, kubeconfig, token, contextName string) (*models.Cluster, error) { if configType == "" { @@ -40,33 +74,32 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco finalKubeconfig := kubeconfig if configType == "token" { - // 安全默认:不嵌入 CA、不跳过 TLS 验证,依赖系统 CA 池。 - // 自签证书的集群请改用 kubeconfig 模式(其内含 CA 证书)。 - finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", false) + // Token 模式通常用于内网/自签证书集群:默认跳过 TLS 校验。 + // 如需严格校验,请使用 kubeconfig 模式(其中包含 CA 证书)。 + finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", true) // Token 模式默认跳过 TLS 校验,兼容内网自签证书 } // 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取 if apiServer == "" { apiServer = ExtractAPIServer(finalKubeconfig) } - path, err := SaveKubeConfig(name, finalKubeconfig) - if err != nil { - return nil, fmt.Errorf("kubeconfig 保存失败: %w", err) - } - + // 安全加固:kubeconfig 只加密存 DB,不再落明文文件 cluster := models.Cluster{ ID: repository.GetDB().NextID("clusters"), Name: name, Description: description, ConfigType: configType, APIServer: apiServer, - KubeConfig: finalKubeconfig, - KubeConfigPath: path, + KubeConfig: "", + KubeConfigPath: "", Context: contextName, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now(), } + if err := cluster.EncryptAndSetKubeconfig(finalKubeconfig); err != nil { + return nil, fmt.Errorf("kubeconfig 加密失败: %w", err) + } db := repository.GetDB() db.Lock() @@ -144,18 +177,24 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error { if v, ok := updates["apiServer"].(string); ok { db.Clusters[i].APIServer = v } - // kubeconfig 编辑约定:非空才更新(留空=保留旧密文) + // 凭据:kubeconfig 或 token 二选一,非空才更新,统一加密存 DB 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 - if db.Clusters[i].KubeConfigPath != "" { - _ = os.WriteFile(db.Clusters[i].KubeConfigPath, []byte(v), 0600) + } else if tok, ok := updates["token"].(string); ok && tok != "" { + apiServer := db.Clusters[i].APIServer + if v, ok := updates["apiServer"].(string); ok && v != "" { + apiServer = v + } + if apiServer == "" { + return fmt.Errorf("Token 接入需要 API Server") + } + kc := BuildKubeconfigFromToken(apiServer, tok, "", true) // Token 模式默认跳过 TLS 校验 + if enc, err := crypto.EncryptField(kc); err == nil { + db.Clusters[i].KubeConfig = enc + db.Clusters[i].KubeConfigPath = "" } } db.Clusters[i].UpdatedAt = time.Now() diff --git a/backend/services/cluster_info.go b/backend/services/cluster_info.go index e72fc4c..c0018a5 100644 --- a/backend/services/cluster_info.go +++ b/backend/services/cluster_info.go @@ -3,6 +3,8 @@ package services import ( "context" "fmt" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/clientcmd" "opsxloki/models" @@ -41,10 +43,15 @@ func GetClusterInfo(ctx context.Context, cluster *models.Cluster) (*ClusterInfo, info.Version = version.GitVersion } nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) - if err != nil { + if err != nil && !apierrors.IsForbidden(err) { return nil, fmt.Errorf("获取节点列表失败: %w", err) } - for _, item := range nodes.Items { + // 命名空间级 Token 通常没有 nodes 权限:跳过节点信息,不影响集群可用性 + var nodeItems []corev1.Node + if nodes != nil { + nodeItems = nodes.Items + } + for _, item := range nodeItems { status := "Unknown" for _, cond := range item.Status.Conditions { if cond.Type == "Ready" { diff --git a/backend/services/cluster_workloads.go b/backend/services/cluster_workloads.go index 63944f8..decdef0 100644 --- a/backend/services/cluster_workloads.go +++ b/backend/services/cluster_workloads.go @@ -7,6 +7,7 @@ import ( "sort" "time" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "opsxloki/models" ) @@ -26,6 +27,10 @@ func ListClusterNamespaces(ctx context.Context, cluster *models.Cluster) ([]stri } items, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) if err != nil { + if apierrors.IsForbidden(err) { + // 命名空间级 Token 无权枚举命名空间:返回空列表,由用户手填 + return []string{}, nil + } return nil, fmt.Errorf("读取集群命名空间失败: %w", err) } result := make([]string, 0, len(items.Items)) diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index fb6f513..d2e732c 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -160,8 +160,8 @@ daemonset: resources: limits: - cpu: 200m - memory: 256Mi + cpu: 500m + memory: 512Mi requests: cpu: 100m memory: 128Mi @@ -194,7 +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") + promtailImage := ResolveImageFor("promtail", "grafana/promtail", "3.4.0") extLabels := promtailExternalLabels(clusterName, labelsJSON) return fmt.Sprintf(`apiVersion: v1 kind: ServiceAccount @@ -310,13 +310,15 @@ spec: port: http-metrics initialDelaySeconds: 10 periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 5 resources: requests: - cpu: 50m - memory: 64Mi + cpu: 100m + memory: 128Mi limits: - cpu: 200m - memory: 256Mi + cpu: 500m + memory: 512Mi volumeMounts: - name: config mountPath: /etc/promtail @@ -447,8 +449,8 @@ func SidecarPatchYAML(configMapName string, mount sidecarLogMount) string { mountPath: /tmp resources: limits: - cpu: 200m - memory: 256Mi + cpu: 500m + memory: 512Mi requests: cpu: 100m memory: 128Mi @@ -470,7 +472,7 @@ func SidecarPatchYAML(configMapName string, mount sidecarLogMount) string { port: fluent-bit-http initialDelaySeconds: 5 periodSeconds: 5 -`, volume, configMapName, appMount, ResolveImage("docker.io/fluent/fluent-bit:5.1.5"), mount.volumeName, mount.mountPath, subPath) +`, volume, configMapName, appMount, ResolveImageFor("fluentbit", "fluent/fluent-bit", "5.1.5"), mount.volumeName, mount.mountPath, subPath) } // indentBlock 给每行加前缀(ConfigMap 嵌入配置需缩进) diff --git a/backend/services/image_registry.go b/backend/services/image_registry.go index 5492bc7..674e15f 100644 --- a/backend/services/image_registry.go +++ b/backend/services/image_registry.go @@ -3,6 +3,7 @@ package services // 镜像仓库源 — 全局设置(参照 kuboard 的国内/海外镜像选择): // overseas = docker.io 官方源;cn = daocloud 国内镜像;custom = 用户自填 registry 前缀。 // 影响平台拉取的全部镜像:Loki / Grafana / Promtail / Fluent Bit。 +// 每个组件可单独配置仓库与 tag(Loki/Grafana 的 tag 留空则跟随部署版本)。 import ( "encoding/json" "fmt" @@ -20,15 +21,23 @@ const ( daocloudPrefix = "docker.m.daocloud.io" ) +// ComponentImage 单个组件的镜像配置 +type ComponentImage struct { + Repository string `json:"repository"` // 如 grafana/loki + Tag string `json:"tag"` // 留空表示跟随部署版本 / 使用默认 +} + // ImageRegistryConfig 镜像源配置 type ImageRegistryConfig struct { - Mode string `json:"mode"` // overseas | cn | custom - CustomHost string `json:"customHost"` // custom 模式下的仓库前缀,如 swr.cn-east-2.myhuaweicloud.com/xxx + Mode string `json:"mode"` // overseas | cn | custom + CustomHost string `json:"customHost"` // custom 模式下的仓库前缀 + Images map[string]ComponentImage `json:"images"` // loki/grafana/promtail/fluentbit } type ImageRegistryPublic struct { - Mode string `json:"mode"` - CustomHost string `json:"customHost"` + Mode string `json:"mode"` + CustomHost string `json:"customHost"` + Images map[string]ComponentImage `json:"images"` // 示例预览(前端展示生效镜像) SampleLoki string `json:"sampleLoki"` SampleGrafana string `json:"sampleGrafana"` @@ -36,31 +45,55 @@ type ImageRegistryPublic struct { SampleFluentBit string `json:"sampleFluentBit"` } +var defaultComponentImages = map[string]ComponentImage{ + "loki": {Repository: "grafana/loki", Tag: ""}, // 跟随部署版本 + "grafana": {Repository: "grafana/grafana", Tag: ""}, // 跟随部署版本 + "promtail": {Repository: "grafana/promtail", Tag: "3.4.0"}, + "fluentbit": {Repository: "fluent/fluent-bit", Tag: "5.1.5"}, +} + +func (c *ImageRegistryConfig) fillDefaults() { + if c.Images == nil { + c.Images = map[string]ComponentImage{} + } + for name, def := range defaultComponentImages { + img, ok := c.Images[name] + if !ok || (img.Repository == "" && img.Tag == "") { + c.Images[name] = def + continue + } + if img.Repository == "" { + img.Repository = def.Repository + } + c.Images[name] = img + } +} + 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 db != nil { + db.RLock() + var raw string + for i := range db.Settings { + if db.Settings[i].Key == imageRegistrySettingKey { + raw = db.Settings[i].Value + break + } + } + db.RUnlock() + if raw != "" { + _ = json.Unmarshal([]byte(raw), cfg) + } } if cfg.Mode == "" { cfg.Mode = RegistryOverseas } + cfg.fillDefaults() return cfg } -func SaveImageRegistry(mode, customHost string) error { +func SaveImageRegistry(mode, customHost string, images map[string]ComponentImage) error { if mode != RegistryOverseas && mode != RegistryCN && mode != RegistryCustom { return fmt.Errorf("无效的镜像源模式") } @@ -70,7 +103,15 @@ func SaveImageRegistry(mode, customHost string) error { return fmt.Errorf("自定义镜像仓库地址无效(需含域名,如 swr.cn-east-2.myhuaweicloud.com/your-ns)") } } - data, _ := json.Marshal(&ImageRegistryConfig{Mode: mode, CustomHost: customHost}) + cfg := &ImageRegistryConfig{Mode: mode, CustomHost: customHost, Images: images} + cfg.fillDefaults() + // 清理空白输入 + clean := map[string]ComponentImage{} + for name, img := range cfg.Images { + clean[name] = ComponentImage{Repository: strings.TrimSpace(img.Repository), Tag: strings.TrimSpace(img.Tag)} + } + cfg.Images = clean + data, _ := json.Marshal(cfg) db := repository.GetDB() newID := db.NextID("settings") db.Lock() @@ -97,7 +138,6 @@ func ResolveImage(official string) string { return daocloudPrefix + "/" + strings.TrimPrefix(official, "docker.io/") } if !strings.Contains(strings.SplitN(official, "/", 2)[0], ".") { - // 无仓库前缀的官方镜像(grafana/loki:tag) return daocloudPrefix + "/" + official } return official @@ -110,22 +150,41 @@ func ResolveImage(official string) string { } return official default: - if strings.HasPrefix(official, "docker.io/") { - return official // 官方模式保留全限定名 - } return official } } +// ResolveImageFor 按组件配置解析镜像;组件 tag 为空时使用 fallbackTag(通常是部署版本)。 +func ResolveImageFor(component, fallbackRepo, fallbackTag string) string { + cfg := GetImageRegistry() + img, ok := cfg.Images[component] + repo := fallbackRepo + tag := fallbackTag + if ok { + if strings.TrimSpace(img.Repository) != "" { + repo = strings.TrimSpace(img.Repository) + } + if strings.TrimSpace(img.Tag) != "" { + tag = strings.TrimSpace(img.Tag) + } + } + official := repo + if !strings.Contains(repo, ":") && tag != "" { + official = repo + ":" + tag + } + return ResolveImage(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"), + Images: cfg.Images, + SampleLoki: ResolveImageFor("loki", "grafana/loki", "3.4.0"), + SampleGrafana: ResolveImageFor("grafana", "grafana/grafana", "11.3.0"), + SamplePromtail: ResolveImageFor("promtail", "grafana/promtail", "3.4.0"), + SampleFluentBit: ResolveImageFor("fluentbit", "fluent/fluent-bit", "5.1.5"), } } diff --git a/backend/services/k8s_client.go b/backend/services/k8s_client.go index b1ffc7a..9d4df0d 100644 --- a/backend/services/k8s_client.go +++ b/backend/services/k8s_client.go @@ -16,6 +16,13 @@ import ( // resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件) func resolveKubeconfig(cluster *models.Cluster) (string, error) { if cluster.KubeConfig != "" { + if cluster.IsKubeconfigEncrypted() { + plain := cluster.DecryptKubeconfig() + if plain == "" { + return "", fmt.Errorf("kubeconfig 解密失败(ENCRYPTION_KEY 是否变更?)") + } + return plain, nil + } return cluster.KubeConfig, nil } if cluster.KubeConfigPath != "" { diff --git a/backend/services/k8s_resources.go b/backend/services/k8s_resources.go index babd0f5..59d5b7f 100644 --- a/backend/services/k8s_resources.go +++ b/backend/services/k8s_resources.go @@ -32,6 +32,10 @@ func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace str if apierrors.IsNotFound(err) { _, err = client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{}) } + if apierrors.IsForbidden(err) { + // 命名空间级 Token 无权读取 Namespace 对象:命名空间由管理员预建,直接继续 + return nil + } return err } @@ -84,6 +88,10 @@ func applyManifest(ctx context.Context, cluster *models.Cluster, manifest string } force := true if _, err := resource.Patch(ctx, name, types.ApplyPatchType, data, metav1.PatchOptions{FieldManager: "lokideploy", Force: &force}); err != nil { + // 命名空间级 Token 无权 patch Namespace:视为已由管理员预建,跳过 + if obj.GetKind() == "Namespace" && apierrors.IsForbidden(err) { + continue + } return fmt.Errorf("应用 %s %s/%s: %w", obj.GetKind(), obj.GetNamespace(), name, err) } } diff --git a/backend/services/log_source.go b/backend/services/log_source.go index 654eea2..13ed2ad 100644 --- a/backend/services/log_source.go +++ b/backend/services/log_source.go @@ -319,32 +319,63 @@ func DeleteLogSource(id uint) error { return fmt.Errorf("数据源不存在") } -// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc) +// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host→url;k8s→cluster-svc)。 +// 已存在同名数据源时:平台托管的自动刷新连接信息,用户手填的不覆盖。 func EnsureLogSourceForDeployment(d *models.LokiDeployment) { if d == nil || d.Endpoint == "" { return } + svc := "loki" + port := 3100 + target := &models.LogSource{ + Name: d.Name, TargetType: "cluster-svc", + ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port, + AuthType: "none", Managed: true, + } + if d.Mode == "host-binary" || d.Mode == "host-docker" { + target.TargetType = "url" + target.URL = d.Endpoint + target.ClusterID = nil + } + + db := repository.GetDB() + db.Lock() + for i := range db.LogSources { + if db.LogSources[i].Name != target.Name { + continue + } + if !db.LogSources[i].Managed { + db.Unlock() + return + } + db.LogSources[i].TargetType = target.TargetType + db.LogSources[i].ClusterID = target.ClusterID + db.LogSources[i].Namespace = target.Namespace + db.LogSources[i].Service = target.Service + db.LogSources[i].Port = target.Port + db.LogSources[i].URL = target.URL + db.LogSources[i].UpdatedAt = time.Now() + db.Unlock() + db.RequestSave() + return + } + db.Unlock() + _, _ = CreateLogSource(target) +} + +// EnsureLogSourcesForRunningDeployments 补齐运行中部署的数据源,避免查询页默认没有数据源。 +func EnsureLogSourcesForRunningDeployments() { db := repository.GetDB() db.RLock() - for i := range db.LogSources { - if db.LogSources[i].Name == d.Name { - db.RUnlock() - return + running := make([]models.LokiDeployment, 0, len(db.Deployments)) + for i := range db.Deployments { + d := db.Deployments[i] + if d.Status == "running" && d.Endpoint != "" { + running = append(running, d) } } 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", + for i := range running { + EnsureLogSourceForDeployment(&running[i]) } - 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 index 08d777f..a3e4e6c 100644 --- a/backend/services/loki_config.go +++ b/backend/services/loki_config.go @@ -113,10 +113,11 @@ func (d *LokiDeployer) generateLokiConfig() string { compactorStore := "filesystem" switch storageType { - case "s3", "oss", "minio": + case "s3", "oss", "minio", "cos", "obs": objectStore = "s3" rulerStore = "s3" - compactorStore = "s3" + // 删除请求存本地盘,避免各云 S3 兼容实现差异导致 compactor 启动失败 + compactorStore = "filesystem" case "gcs": objectStore = "gcs" rulerStore = "gcs" @@ -201,13 +202,19 @@ func (d *LokiDeployer) buildStorageConfigBlock() string { secret_access_key: %s insecure: %s s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure) - case "oss": - return fmt.Sprintf(` alibabacloud: - bucket: %s + case "oss", "cos", "obs": + 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`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey) + secret_access_key: %s + insecure: %s + s3forcepathstyle: false`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey, insecure) case "minio": insecure := "false" if sc.Insecure { @@ -259,13 +266,19 @@ func (d *LokiDeployer) buildRulerStorageBlock() string { secret_access_key: %s insecure: %s s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure) - case "oss": + case "oss", "cos", "obs": + 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 - s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey) + insecure: %s + s3forcepathstyle: false`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey, insecure) case "minio": insecure := "false" if sc.Insecure { @@ -341,8 +354,8 @@ compactor: retention_delete_delay: 2h limits_config: retention_period: %dh - ingestion_rate_mb: 16 - ingestion_burst_size_mb: 32 + ingestion_rate_mb: 64 + ingestion_burst_size_mb: 128 schema_config: configs: - from: "2024-01-01" diff --git a/backend/services/loki_docker.go b/backend/services/loki_docker.go index c2cd1c6..7fb3aea 100644 --- a/backend/services/loki_docker.go +++ b/backend/services/loki_docker.go @@ -21,7 +21,7 @@ func dockerLokiImage(version string) (string, error) { } for _, supported := range LokiVersionList { if supported == version && strings.HasPrefix(version, "3.") { - return "grafana/loki:" + version, nil + return ResolveImageFor("loki", "grafana/loki", version), nil } } return "", fmt.Errorf("主机 Docker 部署请选择支持的 Loki 3.x 版本") diff --git a/backend/services/loki_manifest.go b/backend/services/loki_manifest.go index 9e0cba1..e0d5e98 100644 --- a/backend/services/loki_manifest.go +++ b/backend/services/loki_manifest.go @@ -9,14 +9,14 @@ import ( func (d *LokiDeployer) generateManifest(ns string) string { version := d.effectiveVersion() - image := ResolveImage("docker.io/grafana/loki:" + version) + image := ResolveImageFor("loki", "grafana/loki", version) replicas := d.deployment.Replicas if replicas < 1 { replicas = 1 } storageSize := d.deployment.StorageSize if storageSize == "" { - storageSize = "10Gi" + storageSize = "20Gi" } cpu := d.deployment.ResourcesCPU @@ -139,7 +139,7 @@ spec: fsGroup: 0 containers: - name: loki - image: grafana/loki:%s + image: %s imagePullPolicy: IfNotPresent args: - -config.file=/etc/loki/loki.yaml%s%s @@ -155,8 +155,8 @@ spec: cpu: "%s" memory: "%s" requests: - cpu: "200m" - memory: "256Mi" + cpu: "500m" + memory: "512Mi" securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false diff --git a/backend/services/loki_object_store_test.go b/backend/services/loki_object_store_test.go new file mode 100644 index 0000000..e94cf61 --- /dev/null +++ b/backend/services/loki_object_store_test.go @@ -0,0 +1,52 @@ +package services + +import ( + "fmt" + "strings" + "testing" + + "opsxloki/models" +) + +// TestObjectStoreConfigs 验证 OSS / COS / OBS 生成的 Loki 配置: +// 走 S3 兼容(object_store: s3 + bucketnames),虚拟主机风格,compactor 用本地盘。 +func TestObjectStoreConfigs(t *testing.T) { + for _, typ := range []string{"oss", "cos", "obs"} { + t.Run(typ, func(t *testing.T) { + d := &models.LokiDeployment{ + Name: "test-loki", + Namespace: "log", + Mode: "k8s-yaml", + Replicas: 1, + RetentionDays: 15, + StorageConfig: &models.StorageConfig{ + StorageType: typ, + Bucket: "test-bucket", + Endpoint: "oss-cn-hongkong.aliyuncs.com", + Region: "oss-cn-hongkong", + AccessKey: "AKTEST", + SecretKey: "SKTEST", + PathPrefix: "loki", + }, + } + cfg := NewLokiDeployer(d, func(level, message, detail string) {}).generateLokiConfig() + fmt.Printf("---- %s ----\n%s\n", typ, cfg) + + if strings.Contains(cfg, "alibabacloud") { + t.Fatal("storage_config still contains alibabacloud") + } + for _, want := range []string{ + "object_store: s3", + "bucketnames: test-bucket", + "endpoint: oss-cn-hongkong.aliyuncs.com", + "region: oss-cn-hongkong", + "s3forcepathstyle: false", + "delete_request_store: filesystem", + } { + if !strings.Contains(cfg, want) { + t.Fatalf("missing %q", want) + } + } + }) + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3c55938..a659b25 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -64,7 +64,7 @@ services: dockerfile: Dockerfile args: TARGETARCH: ${TARGETARCH:-amd64} - image: opsxloki:v2 + image: lghost/opsxloki:latest container_name: opsxloki-1 hostname: opsxloki-1 restart: unless-stopped @@ -145,7 +145,7 @@ services: dockerfile: Dockerfile args: TARGETARCH: ${TARGETARCH:-amd64} - image: opsxloki:v2 + image: lghost/opsxloki:latest container_name: opsxloki-2 hostname: opsxloki-2 restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index a221195..584affb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: dockerfile: Dockerfile args: TARGETARCH: ${TARGETARCH:-amd64} - image: opsxloki:v2 + image: lghost/opsxloki:latest container_name: opsxloki hostname: opsxloki diff --git a/docs/deployment/README.md b/docs/deployment/README.md new file mode 100644 index 0000000..e10ae11 --- /dev/null +++ b/docs/deployment/README.md @@ -0,0 +1,46 @@ +# 部署说明 + +本文档描述 `opsx-loki` 的两种部署方式,域名与 IP 均为脱敏示例: + +- `opsx-loki.xxx.io` —— 平台 UI 域名 +- `log.xxx.io` —— Loki 查询域名(Ingress) +- `192.168.x.x` —— 内网地址占位 + +## 架构 + +``` +浏览器 + │ HTTPS(WAF / SLB 终止) + ▼ +边缘 WAF ──► 主机 nginx(192.168.x.x) + │ + ├─ 主机部署:proxy_pass http://127.0.0.1:8081 + └─ K8s 部署:proxy_pass http://:30080 + │ + ▼ + Service opsx-loki:8080 + │ + ▼ + Deployment opsx-loki(1 副本) + │ + ┌────────────────┴────────────────┐ + ▼ ▼ + PVC /app/data Loki Service log/loki:3100 + (JSON DB、kubeconfig 加密) (日志存储:S3/OSS/COS/OBS) +``` + +## 两种方式的取舍 + +| | 主机 Docker | Kubernetes | +|---|---|---| +| 适用 | 单机、快速验证 | 多环境、统一调度 | +| 数据 | 本地卷 `/app/data` | PVC(默认 StorageClass) | +| 域名 | 主机 nginx 反代 `127.0.0.1:8081` | NodePort + nginx,或 ALB Ingress | +| 约束 | 单实例 | **1 副本 + Recreate**(JSON DB 单写者) | + +## 文档索引 + +| 文档 | 内容 | +|---|---| +| [host-nginx.md](host-nginx.md) | 主机部署的 nginx 反代配置(含 WAF 真实 IP、白名单) | +| [kubernetes.md](kubernetes.md) | K8s 清单、Ingress(ALB)、PVC、Secret、升级 | diff --git a/docs/deployment/host-nginx.md b/docs/deployment/host-nginx.md new file mode 100644 index 0000000..1133b9e --- /dev/null +++ b/docs/deployment/host-nginx.md @@ -0,0 +1,69 @@ +# 主机部署:nginx 反代 + +适用于 `docker compose` 单机部署。平台监听 `127.0.0.1:8081`(或容器映射端口),由主机 nginx 对外提供 HTTP/HTTPS。 + +> 域名、IP 已脱敏;实际使用时替换为你的值。 + +## nginx vhost + +`/etc/nginx/conf.d/opsx-loki.xxx.io.conf`: + +```nginx +server { + listen 80; + server_name opsx-loki.xxx.io; + + access_log /var/log/nginx/opsx-loki.xxx.io_access.log main; + error_log /var/log/nginx/opsx-loki.xxx.io_error.log; + + location / { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + client_max_body_size 100m; + } +} +``` + +## 前置 WAF / SLB 时(可选) + +WAF 会把源 IP 改写为内网地址,需要在 `nginx.conf` 的 `http {}` 中还原真实客户端 IP,否则白名单会失效: + +```nginx +set_real_ip_from 192.168.x.x; # WAF 回源地址 +real_ip_header X-Forwarded-For; +``` + +## 内网白名单(可选) + +建议统一放在 `/etc/nginx/nginx.conf` 的 `http {}`,不要散落在各 vhost(vhost 内一旦出现 `allow/deny` 会切断继承): + +```nginx +allow 192.168.0.0/16; +allow 203.0.113.10; # 办公/跳板机出口 +deny all; +``` + +公网站点单独在其 vhost 内写 `allow all;`。 + +## 生效 + +```bash +nginx -t && nginx -s reload +``` + +## 验证 + +```bash +curl -I -H "Host: opsx-loki.xxx.io" http://127.0.0.1/ +curl -s -X POST http://opsx-loki.xxx.io/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"<你的密码>"}' +``` diff --git a/docs/deployment/kubernetes.md b/docs/deployment/kubernetes.md new file mode 100644 index 0000000..48493e9 --- /dev/null +++ b/docs/deployment/kubernetes.md @@ -0,0 +1,238 @@ +# Kubernetes 部署 + +清单在仓库 `kubernetes/` 目录,域名与 IP 已脱敏。 + +## 1. 命名空间与密钥 + +```bash +kubectl create namespace log # 已存在可跳过 + +kubectl -n log create secret generic opsxloki-secrets \ + --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \ + --from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)" +``` + +> `ENCRYPTION_KEY` 必须固定保存:DB 里的 kubeconfig、对象存储 AK/SK、SSH 私钥都用它加密,丢失后无法解密。 +> 从旧实例迁移时,必须使用**原实例相同的** `JWT_SECRET` / `ENCRYPTION_KEY`。 + +## 2. 数据卷 + +`kubernetes/pvc.yaml`: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: opsxloki-data + namespace: log +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 30Gi +``` + +不写 `storageClassName`,使用集群默认 StorageClass。没有默认 SC 时按云填写: + +| 云 | StorageClass | +|---|---| +| 阿里云 ACK | `alicloud-disk-essd` | +| AWS EKS | `gp3` | +| 腾讯云 TKE | `cbs` | +| 华为云 CCE | `csi-disk` | + +## 3. 工作负载 + +```bash +kubectl apply -f kubernetes/namespace.yaml \ + -f kubernetes/pvc.yaml \ + -f kubernetes/deployment.yaml \ + -f kubernetes/service.yaml +``` + +关键约束(`deployment.yaml` 已内置): + +- `replicas: 1`:JSON DB 单写者,多副本会互相覆盖。 +- `strategy: Recreate`:滚动更新会同时挂同一个 RWO PVC。 +- `DATA_DIR=/app/data`,挂 PVC;`runAsUser/fsGroup: 1000`。 +- 镜像 `lghost/opsxloki:latest`,`imagePullPolicy: Always`。 +- 探针:`/api/v1/health`。 + +## 4. Service + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: opsx-loki + namespace: log +spec: + type: ClusterIP + selector: + app: opsx-loki + ports: + - name: http + port: 8080 + targetPort: http +``` + +需要主机 nginx 反代时,额外创建 NodePort(`kubernetes/service-nodeport.yaml`,30080)。 + +## 5. Ingress(ALB) + +`log.xxx.io` 指向 Loki/平台入口,使用阿里云 ALB IngressClass: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: loki.xxx.io + namespace: log + labels: + app: opsx-loki +spec: + ingressClassName: alb + rules: + - host: log.xxx.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: opsx-loki + port: + number: 8080 +``` + +```bash +kubectl apply -f kubernetes/ingress.yaml +kubectl -n log get ingress +``` + +ALB 会分配一个地址(如 `alb-xxxx..alb.aliyuncsslbintl.com`),把域名的 CNAME 指过去即可。 + +> 其他云把 `ingressClassName` 换成对应实现:TKE `qcloud`、CCE `nginx`/`cce`、自建 `nginx`。 + +## 6. 数据迁移(从主机/旧实例) + +JSON DB 是单文件,迁移时先停旧实例,再写入 PVC: + +```bash +# 1) 缩容到 0,避免应用关机时覆盖 +kubectl -n log scale deploy/opsx-loki --replicas=0 + +# 2) 临时 Pod 挂 PVC 拷数据(与业务 Pod 同节点,RWO 盘才能挂上) +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: Pod +metadata: {name: pvc-loader, namespace: log} +spec: + restartPolicy: Never + securityContext: {runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000} + containers: + - name: loader + image: busybox:1.36 + command: ["sleep", "1800"] + volumeMounts: [{name: data, mountPath: /data}] + volumes: + - name: data + persistentVolumeClaim: {claimName: opsxloki-data} +EOF + +kubectl -n log cp ./opsxloki.json pvc-loader:/data/opsxloki.json +kubectl -n log cp ./known_hosts pvc-loader:/data/known_hosts + +kubectl -n log delete pod pvc-loader +kubectl -n log scale deploy/opsx-loki --replicas=1 +``` + +> 不要在业务 Pod 运行时 `kubectl cp` 覆盖 DB:应用退出时会用内存数据回写,覆盖你拷进去的文件。 + +## 7. 验证 + +```bash +kubectl -n log get pods,pvc,svc,ingress +kubectl -n log logs deploy/opsx-loki --tail=20 +kubectl -n log exec deploy/opsx-loki -- wget -qO- http://127.0.0.1:8080/api/v1/health +``` + +## 8. 升级 + +```bash +kubectl -n log set image deploy/opsx-loki opsx-loki=lghost/opsxloki:v0.2 +kubectl -n log rollout status deploy/opsx-loki +``` + +`Recreate` 会先停旧 Pod 再起新 Pod,期间平台短暂不可用(单写者约束的代价)。 + +## 9. Token 接入(长期有效,仅 `log` 命名空间) + +平台支持 ServiceAccount Token 接入。**推荐使用长期 Token + 命名空间级 Role**: +Token 不过期,但权限只覆盖 `log` 命名空间,泄露影响面同样仅限于 `log`,不会波及其他命名空间。 + +在集群里执行一次(管理员权限),然后取 Token: + +```bash +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: Namespace +metadata: + name: log +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: opsx-loki + namespace: log +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: opsx-loki + namespace: log +rules: + - apiGroups: [""] + resources: ["pods", "pods/log", "services", "configmaps", "secrets", "persistentvolumeclaims", "events", "serviceaccounts"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["statefulsets", "daemonsets", "deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: opsx-loki + namespace: log +subjects: + - kind: ServiceAccount + name: opsx-loki + namespace: log +roleRef: + kind: Role + name: opsx-loki + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: v1 +kind: Secret +metadata: + name: opsx-loki-token + namespace: log + annotations: + kubernetes.io/service-account.name: opsx-loki +type: kubernetes.io/service-account-token +EOF + +kubectl -n log get secret opsx-loki-token -o jsonpath='{.data.token}' | base64 -d +``` + +- 这个 Token **不会自动过期**,但只绑定 `log` 命名空间的 Role:能操作该命名空间的 Pod、日志、工作负载、Service、ConfigMap、Secret、PVC,**无权访问其他命名空间和集群级资源**。 +- 平台展示节点数需要 `nodes` 只读权限;命名空间级 Token 没有该权限时,平台会跳过节点信息,不影响集群接入与部署。 +- 平台收到 Token 后会转成 kubeconfig,并用 `ENCRYPTION_KEY` 加密存 DB(`ENC:` 前缀),接口不返回明文。 +- 轮换:删除 Secret 后重建,旧 Token 立即失效。 +- 如果集群策略要求短期凭证,可改用 `kubectl -n log create token opsx-loki --duration=24h` 定期轮换。 +- 上面的 Namespace 由管理员创建,平台 Token 本身没有集群级权限;只有确实需要平台自建命名空间时,才额外授予 `namespaces: create/get` 的 ClusterRole(create 无法按名称限制,预建 `log` 更安全)。 + diff --git a/frontend/src/api/settings/index.ts b/frontend/src/api/settings/index.ts index 7d05f9f..5c7679e 100644 --- a/frontend/src/api/settings/index.ts +++ b/frontend/src/api/settings/index.ts @@ -1,9 +1,15 @@ // 设置域 API — 镜像源等平台级配置 import { http } from '../http' +export interface ComponentImage { + repository: string + tag: string +} + export interface ImageRegistryConfig { mode: 'overseas' | 'cn' | 'custom' customHost: string + images: Record sampleLoki: string sampleGrafana: string samplePromtail: string @@ -12,6 +18,6 @@ export interface ImageRegistryConfig { export const settingsApi = { getImageRegistry: () => http.get('/settings/image-registry'), - saveImageRegistry: (data: { mode: string; customHost?: string }) => + saveImageRegistry: (data: { mode: string; customHost?: string; images?: Record }) => http.put('/settings/image-registry', data), } diff --git a/frontend/src/api/storage/index.ts b/frontend/src/api/storage/index.ts index 7b1d82c..9073742 100644 --- a/frontend/src/api/storage/index.ts +++ b/frontend/src/api/storage/index.ts @@ -4,7 +4,7 @@ import type { PageData, StorageConfig } from '@/types'; export interface StorageCreateInput { name: string; - storageType: 's3' | 'oss' | 'minio' | 'rustfs'; + storageType: 's3' | 'oss' | 'cos' | 'obs' | 'minio' | 'rustfs'; endpoint?: string; bucket: string; region?: string; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 60cc10e..1932ef4 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -58,7 +58,7 @@ function DialogContent({ 1000', + desc: '所有命名空间 error 日志总量超过 1000 条', + query: '{namespace=~".+"} |~ "(?i)error"', + windowMin: 15, threshold: 1000, cooldownMin: 60, + }, + { + label: 'Ingress 5xx', + desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)', + query: '{namespace="ingress-nginx"} | json | status >= 500', + windowMin: 5, threshold: 5, cooldownMin: 15, + }, + { + label: 'Ingress 4xx', + desc: 'ingress-nginx 4xx(需 JSON access log)', + query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500', + windowMin: 5, threshold: 50, cooldownMin: 30, + }, + { + label: 'FATAL', + desc: '所有命名空间出现 FATAL 即告警', + query: '{namespace=~".+"} |= "FATAL"', + windowMin: 5, threshold: 0, cooldownMin: 15, + }, +] + /** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */ const AlertsPage = memo(function AlertsPage() { const [rules, setRules] = useState([]) @@ -87,6 +130,14 @@ const AlertsPage = memo(function AlertsPage() { setOpen(true) } + const applyTemplate = (t: AlertTemplate) => { + form.setValue('name', t.label) + form.setValue('query', t.query) + form.setValue('windowMin', String(t.windowMin)) + form.setValue('threshold', String(t.threshold)) + form.setValue('cooldownMin', String(t.cooldownMin)) + } + const onSubmit = form.handleSubmit(async (v) => { setSubmitting(true) try { @@ -244,6 +295,26 @@ const AlertsPage = memo(function AlertsPage() {
+ {!editing && ( +
+

快捷模板(点击预填,可再修改)

+
+ {TEMPLATES.map((t) => ( + + ))} +
+
+ )}
( diff --git a/frontend/src/pages/clusters/components/ClusterFormDialog.tsx b/frontend/src/pages/clusters/components/ClusterFormDialog.tsx index 421d738..3518f26 100644 --- a/frontend/src/pages/clusters/components/ClusterFormDialog.tsx +++ b/frontend/src/pages/clusters/components/ClusterFormDialog.tsx @@ -247,6 +247,89 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({ )} /> +
+ + 最小授权建议(点击展开) + +
+

+ 只授予 log 命名空间权限,Token 泄露也不会影响其他命名空间; + 不要使用 cluster-admin。 +

+

+ 一次性创建 log 命名空间、ServiceAccount、命名空间级 Role 和长期 Token + (在集群里执行一次,Token 粘贴到下方输入框): +

+
{`# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token
+kubectl apply -f - <<'EOF'
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: log
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: opsx-loki
+  namespace: log
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: opsx-loki
+  namespace: log
+rules:
+  - apiGroups: [""]
+    resources: ["pods", "pods/log", "services", "configmaps", "secrets", "persistentvolumeclaims", "events", "serviceaccounts"]
+    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
+  - apiGroups: ["apps"]
+    resources: ["statefulsets", "daemonsets", "deployments"]
+    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
+  - apiGroups: ["networking.k8s.io"]
+    resources: ["ingresses"]
+    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: opsx-loki
+  namespace: log
+subjects:
+  - kind: ServiceAccount
+    name: opsx-loki
+    namespace: log
+roleRef:
+  kind: Role
+  name: opsx-loki
+  apiGroup: rbac.authorization.k8s.io
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: opsx-loki-token
+  namespace: log
+  annotations:
+    kubernetes.io/service-account.name: opsx-loki
+type: kubernetes.io/service-account-token
+EOF
+
+# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框
+kubectl -n log get secret opsx-loki-token -o jsonpath='{.data.token}' | base64 -d`}
+

+ 上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。 + 只有确实需要平台自建命名空间时,才额外授予 + namespaces: create/get 的 ClusterRole; + 注意 namespace 的 create 无法按名称限制,所以预建 log 更安全。 +

+

+ Token 长期有效,但只绑定 log 命名空间的 Role + (仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 log; + 服务端会转成 kubeconfig 并用 ENCRYPTION_KEY 加密存储, + 数据库里只有 ENC: 密文,接口不返回明文。 + 轮换时删除该 Secret 重建即可,旧 Token 立即失效。 +

+
+
diff --git a/frontend/src/pages/deployments/components/DeploymentCard.tsx b/frontend/src/pages/deployments/components/DeploymentCard.tsx index 9023285..0ccfc73 100644 --- a/frontend/src/pages/deployments/components/DeploymentCard.tsx +++ b/frontend/src/pages/deployments/components/DeploymentCard.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useState } from 'react' +import { Link } from 'react-router' import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink, Pencil, Rocket } from 'lucide-react' import { Button } from '@/components/ui/button' import { StatusBadge } from '@/components/StatusBadge' @@ -91,7 +92,12 @@ export const DeploymentCard = memo(function DeploymentCard({ ) }] : []), ]} - message={d.statusMessage || undefined} + message={ + d.statusMessage || + (d.status === 'running' + ? '提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。' + : undefined) + } actions={ <> )} + {d.status === 'running' && ( + <> + + + + )} diff --git a/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx b/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx index 9dcfe43..daa9176 100644 --- a/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx +++ b/frontend/src/pages/deployments/components/DeploymentFormDialog.tsx @@ -347,7 +347,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({ 存储容量 - + @@ -363,7 +363,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({ CPU - + @@ -376,7 +376,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({ 内存 - + diff --git a/frontend/src/pages/deployments/components/RetentionDialog.tsx b/frontend/src/pages/deployments/components/RetentionDialog.tsx index df97b91..b37e232 100644 --- a/frontend/src/pages/deployments/components/RetentionDialog.tsx +++ b/frontend/src/pages/deployments/components/RetentionDialog.tsx @@ -98,7 +98,7 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo {liveConfig && (

{liveConfig.location}

-
{liveConfig.yaml}
+
{liveConfig.yaml}
)}
diff --git a/frontend/src/pages/deployments/form-schema.ts b/frontend/src/pages/deployments/form-schema.ts index 6edb87b..15228f9 100644 --- a/frontend/src/pages/deployments/form-schema.ts +++ b/frontend/src/pages/deployments/form-schema.ts @@ -15,6 +15,17 @@ const isPositiveInt = (v: string, min: number, max?: number) => { return Number.isInteger(n) && n >= min && (max === undefined || n <= max) } +// 解析存储容量为 GiB(支持 Gi/Mi/Ti),无法解析返回 null +const parseGiB = (v: string): number | null => { + const m = /^(\d+(?:\.\d+)?)\s*(Gi|Mi|Ti)$/i.exec(v.trim()) + if (!m) return null + const n = Number(m[1]) + const unit = m[2].toLowerCase() + if (unit === 'ti') return n * 1024 + if (unit === 'mi') return n / 1024 + return n +} + export const deploymentFormSchema = z .object({ name: z.string().min(1, '请输入部署名称'), @@ -36,7 +47,7 @@ export const deploymentFormSchema = z .refine((v) => isPositiveInt(v, 1, 10), '副本数需为 1-10 之间的整数'), resourcesCPU: z.string().min(1, '请填写 CPU 配额(如 500m)'), resourcesMem: z.string().min(1, '请填写内存配额(如 512Mi)'), - storageSize: z.string().min(1, '请填写存储容量(如 10Gi)'), + storageSize: z.string().min(1, '请填写存储容量(如 20Gi)'), }) .superRefine((data, ctx) => { if (isK8sMode(data.mode) && !data.clusterID) { @@ -51,6 +62,12 @@ export const deploymentFormSchema = z if (isK8sMode(data.mode) && !data.namespace.trim()) { ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' }) } + if (isK8sMode(data.mode)) { + const gi = parseGiB(data.storageSize) + if (gi === null || gi < 20) { + ctx.addIssue({ code: 'custom', path: ['storageSize'], message: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上' }) + } + } }) export type DeploymentFormValues = z.infer @@ -69,7 +86,7 @@ export const DEFAULT_VALUES: DeploymentFormValues = { authEnabled: false, retentionDays: '30', replicas: '1', - resourcesCPU: '500m', - resourcesMem: '512Mi', - storageSize: '10Gi', + resourcesCPU: '1', + resourcesMem: '2Gi', + storageSize: '20Gi', } diff --git a/frontend/src/pages/logs/components/LogContextDialog.tsx b/frontend/src/pages/logs/components/LogContextDialog.tsx index 690d17b..b6e373d 100644 --- a/frontend/src/pages/logs/components/LogContextDialog.tsx +++ b/frontend/src/pages/logs/components/LogContextDialog.tsx @@ -20,7 +20,7 @@ function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean; export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) { return { if (!open) onClose() }}> - + 同一日志流的前后文 {context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟 diff --git a/frontend/src/pages/logs/components/LokiLabelBrowser.tsx b/frontend/src/pages/logs/components/LokiLabelBrowser.tsx index 396441f..7171e49 100644 --- a/frontend/src/pages/logs/components/LokiLabelBrowser.tsx +++ b/frontend/src/pages/logs/components/LokiLabelBrowser.tsx @@ -24,7 +24,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }: const [filter, setFilter] = useState('') const [loadingLabels, setLoadingLabels] = useState(false) const [loadingValues, setLoadingValues] = useState(false) - const [scoped, setScoped] = useState(false) + const [scoped, setScoped] = useState(true) const selected = useMemo(() => currentLabels(query), [query]) const startNs = Number.isFinite(new Date(start).getTime()) ? new Date(start).getTime() * 1e6 : undefined const endNs = Number.isFinite(new Date(end).getTime()) ? new Date(end).getTime() * 1e6 : undefined @@ -32,7 +32,20 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }: useEffect(() => { if (!open || !sourceId) return let active = true - logsApi.labels(sourceId, startNs, endNs).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) }) + logsApi.labels(sourceId, startNs, endNs).then(next => { + if (!active) return + // namespace 优先,其次 app/pod/container,其余按字母序 + const order = ['namespace', 'app', 'service_name', 'pod', 'container', 'job'] + const ordered = [...next].sort((a, b) => { + const ia = order.indexOf(a), ib = order.indexOf(b) + return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b) + }) + setLabels(ordered) + if (!activeLabel) { + const first = ordered.includes('namespace') ? 'namespace' : ordered[0] || '' + if (first) { setActiveLabel(first); setLoadingValues(true) } + } + }).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) }) return () => { active = false } }, [open, sourceId, startNs, endNs]) @@ -48,6 +61,16 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }: const shown = values.filter(value => value.toLowerCase().includes(filter.toLowerCase())).slice(0, 200) + // 选 namespace 时重置 selector(清掉旧 app/pod,避免跨 namespace 残留条件),保留后续管道 + const choose = (label: string, value: string) => { + if (label === 'namespace') { + const j = query.indexOf('}') + onQueryChange(`{namespace=${JSON.stringify(value)}}${j >= 0 ? query.slice(j + 1) : ''}`) + } else { + onQueryChange(applyLabelFilter(query, label, value)) + } + } + return
)} - {filter.trim() && !values.includes(filter.trim()) && } -

候选来自当前时间范围内已写入 Loki 的日志;Kubernetes 存在的命名空间未必有日志。授权范围仍由服务端限制。

+
{loadingValues ?

加载中…

: shown.length === 0 ?

没有匹配值

: shown.map(value => )}
+ {filter.trim() && !values.includes(filter.trim()) && } +

先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。

} } diff --git a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx index 27c3370..f988ca5 100644 --- a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx +++ b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx @@ -6,36 +6,69 @@ import { applyLabelFilter, extractSelector } from '../query-utils' type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress' +function selectorOf(labels: Record) { + const entries = Object.entries(labels).filter(([, value]) => value.trim()) + if (entries.length === 0) return '' + return `{${entries.map(([key, value]) => `${key}=${JSON.stringify(value.trim())}`).join(',')}}` +} + export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: { sourceId: number query: string onQueryChange: (query: string) => void compact?: boolean }) { - const [app, setApp] = useState('') const [namespace, setNamespace] = useState('') + const [app, setApp] = useState('') const [pod, setPod] = useState('') - const [apps, setApps] = useState([]) const [namespaces, setNamespaces] = useState([]) + const [apps, setApps] = useState([]) const [pods, setPods] = useState([]) + // namespace 列表只加载一次 useEffect(() => { if (!sourceId) return let active = true - Promise.allSettled([ - logsApi.labelValues(sourceId, 'app'), - logsApi.labelValues(sourceId, 'namespace'), - logsApi.labelValues(sourceId, 'pod'), - ]).then(([nextApps, nextNamespaces, nextPods]) => { - if (active) { - setApps(nextApps.status === 'fulfilled' ? nextApps.value : []) - setNamespaces(nextNamespaces.status === 'fulfilled' ? nextNamespaces.value : []) - setPods(nextPods.status === 'fulfilled' ? nextPods.value : []) - } - }) + logsApi.labelValues(sourceId, 'namespace') + .then(next => { if (active) setNamespaces(next) }) + .catch(() => { if (active) setNamespaces([]) }) return () => { active = false } }, [sourceId]) + // app 候选:选了 namespace 就只在该命名空间内搜索 + useEffect(() => { + if (!sourceId) return + let active = true + const selector = selectorOf({ namespace }) + logsApi.labelValues(sourceId, 'app', selector) + .then(next => { if (active) setApps(next) }) + .catch(() => { if (active) setApps([]) }) + return () => { active = false } + }, [sourceId, namespace]) + + // pod 候选:按 namespace + app 收窄 + useEffect(() => { + if (!sourceId) return + let active = true + const selector = selectorOf({ namespace, app }) + logsApi.labelValues(sourceId, 'pod', selector) + .then(next => { if (active) setPods(next) }) + .catch(() => { if (active) setPods([]) }) + return () => { active = false } + }, [sourceId, namespace, app]) + + function chooseNamespace(value: string) { + setNamespace(value) + // 换命名空间后旧的 app / pod 条件失效 + setApp('') + setPod('') + } + + function chooseApp(value: string) { + setApp(value) + setPod('') + } + function generate(template: Template) { let next = extractSelector(query) || '{}' if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx') @@ -54,18 +87,18 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: return
{!compact &&

快捷生成 LogQL

} - {!compact &&

选 app、namespace 或副本,再生成可编辑的 LogQL。

} + {!compact &&

先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。

}
- - {apps.map(value => {namespaces.map(value => + + {apps.map(value => {pods.map(value =>
@@ -75,7 +108,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: - {!hasScope && 先填写 app、namespace 或 pod,也可在 Label 浏览器选值} + {!hasScope && 先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值}
} diff --git a/frontend/src/pages/logs/index.tsx b/frontend/src/pages/logs/index.tsx index ca34e60..e41338f 100644 --- a/frontend/src/pages/logs/index.tsx +++ b/frontend/src/pages/logs/index.tsx @@ -163,6 +163,20 @@ export default function LogsPage() {
{error && void reloadSources()} />} {tab === 'query' &&
+ {sources.length === 0 && ( +
+

还没有可用的 Loki 数据源

+

+ 请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress); + 跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。 +

+ {admin && ( + + )} +
+ )}
LogQL
void search()} sourceId={sourceId} history={queryHistory} />
@@ -180,6 +194,10 @@ export default function LogsPage() { {admin && tab === 'access' && } {admin && tab === 'sources' &&

Loki 数据源

+

+ 提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置 + Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。 +

{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
diff --git a/frontend/src/pages/settings/components/ImageRegistrySection.tsx b/frontend/src/pages/settings/components/ImageRegistrySection.tsx index 8956708..e7a9e67 100644 --- a/frontend/src/pages/settings/components/ImageRegistrySection.tsx +++ b/frontend/src/pages/settings/components/ImageRegistrySection.tsx @@ -14,6 +14,13 @@ const MODES = [ { value: 'custom', label: '私有仓库', desc: '自有 registry 前缀(如华为云 SWR)' }, ] as const +const COMPONENTS = [ + { key: 'loki', label: 'Loki', repo: 'grafana/loki', tagHint: '跟随部署版本' }, + { key: 'grafana', label: 'Grafana', repo: 'grafana/grafana', tagHint: '跟随部署版本' }, + { key: 'promtail', label: 'Promtail', repo: 'grafana/promtail', tagHint: '3.4.0' }, + { key: 'fluentbit', label: 'Fluent Bit', repo: 'fluent/fluent-bit', tagHint: '5.1.5' }, +] as const + /** * 镜像源设置 — 影响平台拉取的全部镜像(Loki/Grafana/Promtail/Fluent Bit), * 参照 kuboard 的国内/海外选择;实时预览四类镜像的生效地址。 @@ -40,6 +47,7 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() { const saved = await settingsApi.saveImageRegistry({ mode: cfg.mode, customHost: cfg.mode === 'custom' ? customHost : undefined, + images: cfg.images, }) setCfg(saved) toast.success('镜像源已保存,后续部署/采集将使用新镜像地址') @@ -91,6 +99,43 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
)} + {/* 各服务镜像自定义 */} +
+ +
+ {COMPONENTS.map(({ key, label, repo, tagHint }) => { + const img = cfg.images?.[key] || { repository: '', tag: '' } + const update = (patch: Partial<{ repository: string; tag: string }>) => + setCfg({ + ...cfg, + images: { ...(cfg.images || {}), [key]: { ...img, ...patch } }, + }) + return ( +
+ {label} + update({ repository: e.target.value })} + className="font-mono text-xs" + /> + update({ tag: e.target.value })} + className="font-mono text-xs" + /> +
+ ) + })} +
+

+ 仓库填镜像路径(如 grafana/loki);Tag 留空时 Loki / Grafana 跟随部署版本,Promtail / Fluent Bit 用内置默认。 +

+
+ {/* 生效镜像预览 */}

生效镜像预览

diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx index cc9ee67..8443b81 100644 --- a/frontend/src/pages/settings/index.tsx +++ b/frontend/src/pages/settings/index.tsx @@ -68,12 +68,13 @@ const SettingsPage = memo(function SettingsPage() { ) return ( - + 通用配置 告警通知 LDAP 接入 + 默认镜像源 @@ -175,13 +176,16 @@ const SettingsPage = memo(function SettingsPage() { - + + + + ) diff --git a/frontend/src/pages/storage/components/StorageFormDialog.tsx b/frontend/src/pages/storage/components/StorageFormDialog.tsx index 6fb72d6..b12e6c3 100644 --- a/frontend/src/pages/storage/components/StorageFormDialog.tsx +++ b/frontend/src/pages/storage/components/StorageFormDialog.tsx @@ -29,7 +29,7 @@ import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS, STORAGE_TYPES } from '../c // 表单 schema:zod 为唯一校验源,消息中文且具体 const storageFormSchema = z.object({ name: z.string().min(1, '请输入存储配置名称'), - storageType: z.enum(['s3', 'oss', 'minio', 'rustfs']), + storageType: z.enum(['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs']), endpoint: z.string().optional(), bucket: z.string().min(1, '请填写 Bucket 名称'), region: z.string().optional(), @@ -82,7 +82,8 @@ export const StorageFormDialog = memo(function StorageFormDialog({ }) const storageType = form.watch('storageType') // 区域仅对公有云(s3/oss)有意义;minio/rustfs 为本地部署不显示、不提交 - const isCloudStorage = storageType === 's3' || storageType === 'oss' + const isCloudStorage = + storageType === 's3' || storageType === 'oss' || storageType === 'cos' || storageType === 'obs' // 打开时重置;编辑模式回填(密钥留空=保留旧值) useEffect(() => { @@ -218,7 +219,11 @@ export const StorageFormDialog = memo(function StorageFormDialog({ ? 's3.amazonaws.com' : storageType === 'oss' ? 'oss-cn-hangzhou.aliyuncs.com' - : 'http://10.0.0.8:9000' + : storageType === 'cos' + ? 'cos.ap-guangzhou.myqcloud.com' + : storageType === 'obs' + ? 'obs.cn-north-4.myhuaweicloud.com' + : 'http://10.0.0.8:9000' } {...field} /> @@ -236,7 +241,12 @@ export const StorageFormDialog = memo(function StorageFormDialog({ Bucket * - + @@ -250,7 +260,20 @@ export const StorageFormDialog = memo(function StorageFormDialog({ 区域 - + diff --git a/frontend/src/pages/storage/constants.ts b/frontend/src/pages/storage/constants.ts index 39dc16d..617ca9e 100644 --- a/frontend/src/pages/storage/constants.ts +++ b/frontend/src/pages/storage/constants.ts @@ -4,6 +4,8 @@ import { Cloud, Database, Package, type LucideIcon } from 'lucide-react' export const STORAGE_TYPE_ICONS: Record = { s3: Cloud, oss: Cloud, + cos: Cloud, + obs: Cloud, minio: Database, rustfs: Package, } @@ -11,6 +13,8 @@ export const STORAGE_TYPE_ICONS: Record = { export const STORAGE_TYPE_ICON_CLASS: Record = { s3: 'text-warning', oss: 'text-destructive', + cos: 'text-info', + obs: 'text-info', minio: 'text-info', rustfs: 'text-info', } @@ -18,8 +22,10 @@ export const STORAGE_TYPE_ICON_CLASS: Record = { export const STORAGE_TYPE_COLORS: Record = { s3: 'bg-warning/10 text-warning', oss: 'bg-destructive/10 text-destructive', + cos: 'bg-info/10 text-info', + obs: 'bg-info/10 text-info', minio: 'bg-info/10 text-info', rustfs: 'bg-info/10 text-info', } -export const STORAGE_TYPES = ['s3', 'oss', 'minio', 'rustfs'] as const +export const STORAGE_TYPES = ['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs'] as const diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5c7f920..1541d08 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -94,7 +94,7 @@ export interface HostInfo { export interface StorageConfig { id: number; name: string; - storageType: 's3' | 'oss' | 'minio' | 'rustfs'; + storageType: 's3' | 'oss' | 'cos' | 'obs' | 'minio' | 'rustfs'; endpoint: string; bucket: string; region: string; diff --git a/kubernetes/README.md b/kubernetes/README.md new file mode 100644 index 0000000..3a57e76 --- /dev/null +++ b/kubernetes/README.md @@ -0,0 +1,79 @@ +# opsx-loki 部署到 Kubernetes + +## 文件 + +| 文件 | 说明 | +|---|---| +| `namespace.yaml` | `log` 命名空间(已有可跳过) | +| `secret.example.yaml` | JWT/ENCRYPTION 密钥模板(建议用 `kubectl create secret` 命令创建,不要提交真实值) | +| `pvc.yaml` | 平台数据卷(30Gi,默认 StorageClass) | +| `deployment.yaml` | 平台本体,**单副本 + Recreate** | +| `service.yaml` | ClusterIP `opsx-loki:8080` | +| `ingress.yaml` | 可选,按云厂商的 ingressClass 调整 | + +## 部署步骤 + +```bash +# 1) 密钥(只生成一次,之后复用;丢失会导致已保存的凭证无法解密) +kubectl -n log create secret generic opsxloki-secrets \ + --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \ + --from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)" + +# 2) 数据卷 + 平台 +kubectl apply -f kubernetes/namespace.yaml +kubectl apply -f kubernetes/pvc.yaml +# 修改 deployment.yaml 里的 image 为实际仓库地址后: +kubectl apply -f kubernetes/deployment.yaml +kubectl apply -f kubernetes/service.yaml + +# 3) 可选:Ingress(不需要就跳过,继续走外部 WAF 回源) +kubectl apply -f kubernetes/ingress.yaml +``` + +## 持久化(重要) + +- `/app/data` 挂 PVC,保存 `opsxloki.json`、`kubeconfigs/`、`known_hosts`。 +- **必须 `replicas: 1` + `strategy: Recreate`**:JSON DB 是单写者;滚动更新时两个 Pod 同时挂同一个 RWO PVC 会互相覆盖。 +- DB 写入本身是原子的(临时文件 + fsync + rename),容器被杀不会写坏文件。 +- 备份建议用 CSI VolumeSnapshot(AWS EBS / 腾讯 CBS / 华为 EVS / 阿里云 ESSD 都支持同一套 `snapshot.storage.k8s.io` API): + +```yaml +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshot +metadata: + name: opsxloki-data-20261025 + namespace: log +spec: + volumeSnapshotClassName: <集群的 snapshotClass> + source: + persistentVolumeClaimName: opsxloki-data +``` + +## 多云兼容 + +| 项 | 做法 | +|---|---| +| 存储类 | `pvc.yaml` 不写 `storageClassName`,用集群默认 SC;没有默认 SC 时填对应云的 SC 名(AWS `gp3` / 腾讯 `cbs` / 华为 `csi-disk` / 阿里云 `alicloud-disk-essd`) | +| 镜像仓库 | `deployment.yaml` 的 `image` 填目标云可拉的仓库(Harbor / ACR / TCR / SWR) | +| Loki 存储 | 平台已支持 S3 / OSS / COS / OBS,均为 S3 兼容,无需云厂商 SDK | +| 查询链路 | 平台与 Loki 同集群时,数据源选「集群服务」(namespace `log`、service `loki`、port `3100`),无需 Ingress/WAF | + +## 与外部实例的关系 + +平台的 JSON DB 单写者,**不要两个实例共用同一份数据卷**。要么: +- 集群内新实例 + 迁移旧 `opsxloki.json`(停旧实例后拷贝);或 +- 保留外部 229 实例做带外管理,集群内实例使用独立数据卷(两套数据)。 + +## 日志 + +- 平台**只写 stdout**(Go/GIN 默认),不写日志文件;`kubectl logs` / `docker logs` 可直接看,Pod 部署无需额外配置。 +- Docker Compose:已配置 `json-file` 轮转 `max-size=50m`、`max-file=5`。 +- Kubernetes:由 kubelet 统一轮转,节点级配置建议: + ```yaml + # /var/lib/kubelet/config.yaml + containerLogMaxSize: 50Mi + containerLogMaxFiles: 5 + ``` + 也可以用 kubelet 启动参数 `--container-log-max-size=50Mi --container-log-max-files=5`。 +- Loki / Promtail 同样输出到 stdout,由容器运行时轮转;不要在容器内写业务日志文件。 +- 应用自身的部署日志写在 DB(`deployment_logs`),可在部署详情里查看,不占容器日志空间。 diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml new file mode 100644 index 0000000..2880044 --- /dev/null +++ b/kubernetes/deployment.yaml @@ -0,0 +1,87 @@ +# opsx-loki 平台本体 +# +# 关键约束: +# 1) replicas 必须为 1:JSON DB 为单写者,多副本会互相覆盖。 +# 2) strategy 必须为 Recreate:RollingUpdate 期间会同时存在两个 Pod 挂同一个 +# RWO PVC,两个进程同时写 opsxloki.json 会导致数据丢失。 +# 3) /app/data 必须挂 PVC,否则重启后 DB / kubeconfig / known_hosts 全丢。 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opsx-loki + namespace: log + labels: + app: opsx-loki +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: opsx-loki + template: + metadata: + labels: + app: opsx-loki + spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: opsx-loki + image: lghost/opsxloki:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: DATA_DIR + value: /app/data + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: opsxloki-secrets + key: JWT_SECRET + - name: ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: opsxloki-secrets + key: ENCRYPTION_KEY + # 可选:挂载本地集群 kubeconfig 时启用 + # - name: KUBECONFIG + # value: /home/opsxloki/.kube/config + volumeMounts: + - name: data + mountPath: /app/data + # 可选:本地集群 kubeconfig(in-cluster 管理本集群时用) + # - name: kubeconfig + # mountPath: /home/opsxloki/.kube + # readOnly: true + readinessProbe: + httpGet: + path: /api/v1/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/v1/health + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + failureThreshold: 5 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + volumes: + - name: data + persistentVolumeClaim: + claimName: opsxloki-data + # - name: kubeconfig + # secret: + # secretName: opsxloki-kubeconfig diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml new file mode 100644 index 0000000..6fbc971 --- /dev/null +++ b/kubernetes/ingress.yaml @@ -0,0 +1,24 @@ +# 可选:通过集群 Ingress 暴露平台 UI。 +# 阿里云 ACK 用 ALB IngressClass;腾讯 TKE 用 qcloud;华为 CCE 用 nginx/cce。 +# 若继续走外部 WAF 回源到 Service,则不需要本文件。 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: opsx-loki + namespace: log + annotations: + # 阿里云 ALB 示例;其他云改成对应 ingressClass + alb.ingress.kubernetes.io/healthcheck-path: /api/v1/health +spec: + ingressClassName: alb + rules: + - host: opsx-loki.91ai.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: opsx-loki + port: + number: 8080 diff --git a/kubernetes/namespace.yaml b/kubernetes/namespace.yaml new file mode 100644 index 0000000..073fa64 --- /dev/null +++ b/kubernetes/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: log diff --git a/kubernetes/pvc.yaml b/kubernetes/pvc.yaml new file mode 100644 index 0000000..86ab978 --- /dev/null +++ b/kubernetes/pvc.yaml @@ -0,0 +1,15 @@ +# 平台数据卷:opsxloki.json(JSON DB) + kubeconfigs + known_hosts。 +# storageClassName 留空 = 使用集群默认 SC: +# AWS EBS(gp3) / 腾讯云 CBS / 华为云 EVS / 阿里云 ESSD 均可; +# 若集群没有默认 SC,在这里填对应云的 SC 名称。 +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: opsxloki-data + namespace: log +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 30Gi diff --git a/kubernetes/secret.example.yaml b/kubernetes/secret.example.yaml new file mode 100644 index 0000000..298140c --- /dev/null +++ b/kubernetes/secret.example.yaml @@ -0,0 +1,14 @@ +# 只做模板:不要提交真实密钥。 +# 推荐直接用命令创建(避免密钥落盘到 Git): +# kubectl -n log create secret generic opsxloki-secrets \ +# --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \ +# --from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)" +apiVersion: v1 +kind: Secret +metadata: + name: opsxloki-secrets + namespace: log +type: Opaque +stringData: + JWT_SECRET: "REPLACE_WITH_32_BYTES_HEX" + ENCRYPTION_KEY: "REPLACE_WITH_32_BYTES_HEX" diff --git a/kubernetes/service-nodeport.yaml b/kubernetes/service-nodeport.yaml new file mode 100644 index 0000000..5c0e707 --- /dev/null +++ b/kubernetes/service-nodeport.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: opsx-loki-nodeport + namespace: log + labels: + app: opsx-loki +spec: + type: NodePort + selector: + app: opsx-loki + ports: + - name: http + port: 8080 + targetPort: http + nodePort: 30080 diff --git a/kubernetes/service.yaml b/kubernetes/service.yaml new file mode 100644 index 0000000..7e8c8c7 --- /dev/null +++ b/kubernetes/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: opsx-loki + namespace: log + labels: + app: opsx-loki +spec: + type: ClusterIP + selector: + app: opsx-loki + ports: + - name: http + port: 8080 + targetPort: http