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:
1 parent
2d73661dd1
commit
9f7c522157
50 files changed
+1346
-149
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -27,6 +27,9 @@ func main() {
|
||||
log.Println("Seeding default data...")
|
||||
seed.Seed()
|
||||
|
||||
// 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件
|
||||
services.MigrateClusterKubeconfigs()
|
||||
|
||||
// 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine,
|
||||
// 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。
|
||||
repository.GetDB().RecoverOrphanDeployments()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 嵌入配置需缩进)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 版本")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user