feat: K8s 部署清单、Token 接入加固与日志/设置页优化

- kubernetes/: 30Gi PVC、单副本 Recreate、Secret/Service/NodePort/Ingress 清单与部署文档
- 数据安全: kubeconfig/token 统一加密存储,启动迁移历史明文,DB 0600
- Token 接入: 命名空间级最小权限提示、长期 Token、自签证书 TLS 兼容
- 部署: 命名空间级 Token 下跳过 Namespace patch/枚举,节点信息容错
- 日志页: namespace → app → pod 选择顺序与作用域收窄
- 设置页: 拆分为通用/告警/LDAP/镜像源四个 tab
- 弹框: 统一 max-h 滚动与长命令换行,修复内容溢出
This commit is contained in:
logan committed 2026-10-25 23:44:07 +08:00
1 parent 2d73661dd1
commit 9f7c522157
50 files changed
+1346 -149

No files matched your search

+1 -1
View File
@@ -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
}
+3
View File
@@ -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})
}
+5 -2
View File
@@ -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"`
}
+2 -2
View File
@@ -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"`
+3
View File
@@ -27,6 +27,9 @@ func main() {
log.Println("Seeding default data...")
seed.Seed()
// 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件
services.MigrateClusterKubeconfigs()
// 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine,
// 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。
repository.GetDB().RecoverOrphanDeployments()
+2 -2
View File
@@ -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
}
+56 -17
View File
@@ -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()
+9 -2
View File
@@ -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" {
+5
View File
@@ -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))
+12 -10
View File
@@ -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 嵌入配置需缩进)
+87 -28
View File
@@ -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"),
}
}
+7
View File
@@ -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 != "" {
+8
View File
@@ -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)
}
}
+49 -18
View File
@@ -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)
}
+23 -10
View File
@@ -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"
+1 -1
View File
@@ -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 版本")
+5 -5
View File
@@ -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
@@ -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)
}
}
})
}
}