Files
LokiStack/backend/services/loki_deployer.go
T
logan f9c6c1d826 feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询
基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。

前端(Gin+React+shadcn/ui):
- 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、
  反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放
- 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info)
- 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环)
- 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史
- 按用户+标签策略的查询授权(服务端强制合并策略)

后端(Go+Gin+JSON DB):
- 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流
- LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录
- 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建
- Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、
  凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入
- DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签
- 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页
- 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录

修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、
健康检查 CreatedAt 零值等 10+ 存量 bug。

数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移;
HKDF 加密前缀保留(已加密凭证不受影响)。
2026-10-24 13:20:12 +08:00

1253 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"text/template"
"opsxloki/models"
"opsxloki/pkg/crypto"
)
// LokiDeployer 负责 Loki 的三种部署模式:k8s-helm / k8s-yaml / host-binary
type LokiDeployer struct {
deployment *models.LokiDeployment
logFunc func(level, message, detail string)
}
func NewLokiDeployer(deployment *models.LokiDeployment, logFunc func(level, message, detail string)) *LokiDeployer {
return &LokiDeployer{deployment: deployment, logFunc: logFunc}
}
// Deploy 根据部署模式分发到对应实现
func (d *LokiDeployer) Deploy(ctx context.Context) error {
d.log("info", fmt.Sprintf("Starting Loki deployment: %s (mode: %s, version: %s)",
d.deployment.Name, d.deployment.Mode, d.effectiveVersion()), "")
// 版本兼容性检查
if err := d.checkVersionCompatibility(); err != nil {
d.log("warn", err.Error(), "")
}
var err error
switch d.deployment.Mode {
case "k8s-helm":
err = d.deployHelm(ctx)
case "k8s-yaml":
err = d.deployYaml(ctx)
case "host-binary":
err = d.deployBinary(ctx)
default:
return fmt.Errorf("unsupported deployment mode: %s", d.deployment.Mode)
}
if err != nil {
return err
}
// Loki 就绪后,按需同环境部署 Grafana(数据源自动指向本 Loki)
if d.deployment.GrafanaEnabled {
g := &GrafanaDeployer{deployment: d.deployment, log: d.log}
if gerr := g.Deploy(ctx); gerr != nil {
d.log("error", fmt.Sprintf("Grafana deployment failed: %v", gerr), "")
return fmt.Errorf("grafana deployment failed: %w", gerr)
}
}
return nil
}
// ==================== Helm 部署 (grafana/loki v6+) ====================
func (d *LokiDeployer) deployHelm(ctx context.Context) error {
if d.deployment.Cluster == nil {
return fmt.Errorf("please select target cluster")
}
kubeconfig, err := resolveKubeconfig(d.deployment.Cluster)
if err != nil {
return err
}
kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600)
if err != nil {
return err
}
defer cleanup()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile))
ns := d.effectiveNamespace()
// 创建 namespace(幂等)
d.log("info", fmt.Sprintf("Ensuring namespace %s...", ns), "")
nsCmd := exec.CommandContext(ctx, "kubectl", "create", "namespace", ns, "--dry-run=client", "-o", "yaml")
nsCmd.Env = env
nsOut, _ := nsCmd.CombinedOutput()
applyNs := exec.CommandContext(ctx, "kubectl", "apply", "-f", "-")
applyNs.Env = env
applyNs.Stdin = bytes.NewReader(nsOut)
applyNs.CombinedOutput()
// 添加 Helm repo
d.log("info", "Adding/updating Grafana Helm repo...", "")
repoCmd := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts")
repoCmd.Env = env
repoCmd.CombinedOutput()
updCmd := exec.CommandContext(ctx, "helm", "repo", "update")
updCmd.Env = env
updCmd.CombinedOutput()
// 生成 values.yaml
valuesYAML := d.generateHelmValues()
valuesFile, valuesCleanup, err := writeTempFile("loki-values-*.yaml", valuesYAML, 0600)
if err != nil {
return err
}
defer valuesCleanup()
// Helm install/upgrade - 统一使用 grafana/loki (v6+)
d.log("info", fmt.Sprintf("Installing Loki via Helm (grafana/loki, mode: %s)...", d.helmDeploymentMode()), "")
releaseName := d.effectiveReleaseName()
cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", releaseName, "grafana/loki",
"--namespace", ns, "--create-namespace", "--wait", "--timeout", "10m",
"-f", valuesFile,
)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
d.log("error", fmt.Sprintf("Helm install failed: %v", err), string(out))
return fmt.Errorf("helm install failed: %w\n%s", err, string(out))
}
d.log("success", "Loki installed via Helm", string(out))
return nil
}
// ==================== K8s YAML 部署 (StatefulSet) ====================
func (d *LokiDeployer) deployYaml(ctx context.Context) error {
if d.deployment.Cluster == nil {
return fmt.Errorf("please select target cluster")
}
kubeconfig, err := resolveKubeconfig(d.deployment.Cluster)
if err != nil {
return err
}
kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600)
if err != nil {
return err
}
defer cleanup()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile))
ns := d.effectiveNamespace()
// 生成完整 manifest
manifest := d.generateManifest(ns)
manifestFile, manifestCleanup, err := writeTempFile("loki-*.yaml", manifest, 0600)
if err != nil {
return err
}
defer manifestCleanup()
d.log("info", "Applying Loki manifest (StatefulSet + PVC + ConfigMap + Service)...", "")
cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
d.log("error", fmt.Sprintf("kubectl apply failed: %v", err), string(out))
return fmt.Errorf("kubectl apply failed: %w\n%s", err, string(out))
}
// 等待 StatefulSet ready
d.log("info", "Waiting for StatefulSet to be ready...", "")
waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "statefulset/loki",
"-n", ns, "--timeout=5m")
waitCmd.Env = env
waitOut, _ := waitCmd.CombinedOutput()
d.log("info", string(waitOut), "")
d.log("success", "Loki deployed via YAML manifest", string(out))
return nil
}
// ==================== 主机二进制部署 ====================
func (d *LokiDeployer) deployBinary(ctx context.Context) error {
if d.deployment.Host == nil {
return fmt.Errorf("please select target host")
}
host := d.deployment.Host
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
return err
}
defer client.Close()
// 检测架构
arch, _, _ := client.Execute(ctx, "uname -m")
lokiArch := strings.TrimSpace(arch)
switch lokiArch {
case "x86_64":
lokiArch = "amd64"
case "aarch64":
lokiArch = "arm64"
}
version := d.effectiveVersion()
d.log("info", fmt.Sprintf("SSH connected, downloading Loki %s (%s)...", version, lokiArch), "")
// 创建目录
client.Execute(ctx, "mkdir -p /opt/loki /var/lib/loki /var/lib/loki/rules /var/lib/loki/rules-temp /etc/loki")
// 下载 Loki
downloadURL := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/loki-linux-%s.zip", version, lokiArch)
cmd := fmt.Sprintf("cd /tmp && curl -fsSL -o loki.zip '%s' && unzip -o loki.zip -d /opt/loki/ && chmod +x /opt/loki/loki-linux-%s",
downloadURL, lokiArch)
if _, _, err := client.Execute(ctx, cmd); err != nil {
return fmt.Errorf("download Loki failed: %w", err)
}
client.Execute(ctx, "ln -sf /opt/loki/loki-linux-* /usr/local/bin/loki 2>/dev/null || true")
// 上传配置
config := d.generateLokiConfig()
client.Execute(ctx, fmt.Sprintf("cat > /etc/loki/loki.yaml << 'EOF'\n%s\nEOF", config))
// systemd 服务(生产级配置)
service := `[Unit]
Description=Grafana Loki
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=root
Group=root
ExecStart=/usr/local/bin/loki -config.file=/etc/loki/loki.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=10
LimitNOFILE=65536
StandardOutput=append:/var/log/loki.log
StandardError=append:/var/log/loki.log
[Install]
WantedBy=multi-user.target
`
client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/loki.service << 'EOF'\n%s\nEOF", service))
// 启动服务
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable loki && systemctl restart loki"); err != nil {
return fmt.Errorf("start Loki failed: %w", err)
}
// 健康检查
d.log("info", "Waiting for Loki to be ready...", "")
for i := 0; i < 10; i++ {
_, _, err := client.Execute(ctx, "curl -fsSL http://localhost:3100/ready")
if err == nil {
break
}
client.Execute(ctx, "sleep 2")
}
d.log("success", fmt.Sprintf("Loki %s deployed on %s", version, host.HostIP), "")
return nil
}
// ==================== Helm Values 生成 ====================
func (d *LokiDeployer) generateHelmValues() string {
mode := d.helmDeploymentMode()
version := d.effectiveVersion()
// 构建 storage 配置
storageSection := d.buildHelmStorageSection()
schemaSection := d.buildHelmSchemaSection()
// 资源限制 + 持久化(合并到同一顶层键下,避免重复键导致 Helm 丢弃 resources)
deploymentSection := d.buildHelmDeploymentSection()
// 认证配置
authSection := ""
if d.deployment.AuthEnabled {
authSection = `
auth:
type: basic
basic:
username: admin`
}
values := fmt.Sprintf(`# Auto-generated Loki Helm Values
# Chart: grafana/loki (v6+)
# Deployment Mode: %s
# Version: %s
deploymentMode: %s
loki:
image:
tag: %s
auth_enabled: %v
commonConfig:
replication_factor: %d
path_prefix: /var/loki
%s
limits_config:
retention_period: %dh
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
per_stream_rate_limit: 3MB
per_stream_rate_limit_burst: 15MB
max_chunks_per_query: 2000000
max_query_series: 1000
max_query_parallelism: 32
%s
%s
analytics:
reporting_enabled: false
%s
monitoring:
serviceMonitor:
enabled: false
selfMonitoring:
enabled: false
grafanaAgent:
installOperator: false
lokiCanary:
enabled: false
test:
enabled: false
`, mode, version, mode, version, d.deployment.AuthEnabled,
d.replicationFactor(), storageSection, d.deployment.RetentionDays, schemaSection,
authSection, deploymentSection)
return values
}
func (d *LokiDeployer) helmDeploymentMode() string {
if d.deployment.Replicas > 1 {
return "Distributed"
}
return "SingleBinary"
}
func (d *LokiDeployer) replicationFactor() int {
if d.deployment.Replicas >= 3 {
return 3
}
if d.deployment.Replicas >= 2 {
return 2
}
return 1
}
func (d *LokiDeployer) buildHelmStorageSection() string {
storageType := d.effectiveStorageType()
switch storageType {
case "s3":
sc := d.deployment.StorageConfig
endpoint := sc.Endpoint
if endpoint == "" {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
}
return fmt.Sprintf(`storage:
type: s3
bucketNames:
chunks: %s
ruler: %s-ruler
admin: %s-admin
s3:
endpoint: %s
region: %s
accessKeyId: %s
secretAccessKey: %s
insecure: %v
s3ForcePathStyle: true`, sc.Bucket, sc.Bucket, sc.Bucket, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, sc.Insecure)
case "oss":
sc := d.deployment.StorageConfig
return fmt.Sprintf(`storage:
type: s3
bucketNames:
chunks: %s
ruler: %s-ruler
admin: %s-admin
s3:
endpoint: %s
region: %s
accessKeyId: %s
secretAccessKey: %s`, sc.Bucket, sc.Bucket, sc.Bucket, sc.Endpoint, sc.Region, sc.AccessKey, sc.SecretKey)
case "minio":
sc := d.deployment.StorageConfig
return fmt.Sprintf(`storage:
type: s3
bucketNames:
chunks: %s
ruler: %s-ruler
admin: %s-admin
s3:
endpoint: %s
accessKeyId: %s
secretAccessKey: %s
insecure: %v
s3ForcePathStyle: true`, sc.Bucket, sc.Bucket, sc.Bucket, sc.Endpoint, sc.AccessKey, sc.SecretKey, sc.Insecure)
default:
// filesystem
return `storage:
type: filesystem`
}
}
func (d *LokiDeployer) buildHelmSchemaSection() string {
storageType := d.effectiveStorageType()
objectStore := "filesystem"
switch storageType {
case "s3", "oss", "minio":
objectStore = "s3"
case "gcs":
objectStore = "gcs"
case "azure":
objectStore = "azure"
}
return fmt.Sprintf(`schemaConfig:
configs:
- from: "2024-01-01"
store: tsdb
object_store: %s
schema: v13
index:
prefix: tsdb_index_
period: 24h`, objectStore)
}
// buildHelmDeploymentSection 生成目标组件的 resources + persistence。
// 关键:必须把两者合并到同一顶层键(singleBinary / write / read / backend)之下,
// 否则 resources 与 persistence 各自 emit 一次同名顶层键,YAML/Helm 只取最后一个,
// 导致 resources 限制被静默丢弃。
func (d *LokiDeployer) buildHelmDeploymentSection() string {
cpu := d.deployment.ResourcesCPU
mem := d.deployment.ResourcesMem
if cpu == "" {
cpu = "1000m"
}
if mem == "" {
mem = "1Gi"
}
storageSize := d.deployment.StorageSize
if storageSize == "" {
storageSize = "10Gi"
}
resources := fmt.Sprintf(` resources:
limits:
cpu: %s
memory: %s
requests:
cpu: 200m
memory: 256Mi`, cpu, mem)
persistence := fmt.Sprintf(` persistence:
enabled: true
size: %s
storageClassName: ""`, storageSize)
if d.helmDeploymentMode() == "SingleBinary" {
return fmt.Sprintf(`singleBinary:
replicas: %d
%s
%s`, d.deployment.Replicas, resources, persistence)
}
// Distributed: write/backend 持久化,read 不持久化(无状态)
return fmt.Sprintf(`write:
replicas: %d
%s
%s
read:
replicas: %d
%s
persistence:
enabled: false
backend:
replicas: 1
%s
%s`,
d.deployment.Replicas, resources, persistence,
d.deployment.Replicas, resources,
resources, persistence)
}
// ==================== K8s YAML Manifest 生成 (StatefulSet) ====================
func (d *LokiDeployer) generateManifest(ns string) string {
version := d.effectiveVersion()
replicas := d.deployment.Replicas
if replicas < 1 {
replicas = 1
}
storageSize := d.deployment.StorageSize
if storageSize == "" {
storageSize = "10Gi"
}
cpu := d.deployment.ResourcesCPU
mem := d.deployment.ResourcesMem
if cpu == "" {
cpu = "1000m"
}
if mem == "" {
mem = "1Gi"
}
configData := d.generateLokiConfig()
manifest := fmt.Sprintf(`apiVersion: v1
kind: Namespace
metadata:
name: %s
---
apiVersion: v1
kind: ConfigMap
metadata:
name: loki-config
namespace: %s
data:
loki.yaml: |
%s
---
apiVersion: v1
kind: Service
metadata:
name: loki
namespace: %s
labels:
app: loki
spec:
type: ClusterIP
ports:
- name: http
port: 3100
targetPort: 3100
protocol: TCP
- name: grpc
port: 9095
targetPort: 9095
protocol: TCP
selector:
app: loki
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: loki-data
namespace: %s
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: %s
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: loki
namespace: %s
labels:
app: loki
spec:
serviceName: loki
replicas: %d
selector:
matchLabels:
app: loki
template:
metadata:
labels:
app: loki
spec:
securityContext:
runAsUser: 0
runAsGroup: 0
fsGroup: 0
containers:
- name: loki
image: grafana/loki:%s
imagePullPolicy: IfNotPresent
args:
- -config.file=/etc/loki/loki.yaml
ports:
- name: http
containerPort: 3100
protocol: TCP
- name: grpc
containerPort: 9095
protocol: TCP
resources:
limits:
cpu: "%s"
memory: "%s"
requests:
cpu: "200m"
memory: "256Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
volumeMounts:
- name: config
mountPath: /etc/loki
readOnly: true
- name: data
mountPath: /var/lib/loki
- name: wal
mountPath: /var/lib/loki/wal
volumes:
- name: config
configMap:
name: loki-config
- name: data
persistentVolumeClaim:
claimName: loki-data
- name: wal
emptyDir: {}
`, ns, ns, indentYAML(configData, 4), ns, ns, storageSize, ns, replicas, version, cpu, mem)
// 多副本:追加 Headless Service(clusterIP: None + selector app:loki),
// 使 memberlist 的 join_members (loki-headless.<ns>.svc.cluster.local) 能解析到所有 Pod IP。
if replicas >= 2 {
manifest += fmt.Sprintf(`
---
apiVersion: v1
kind: Service
metadata:
name: loki-headless
namespace: %s
labels:
app: loki
spec:
clusterIP: None
ports:
- name: http
port: 3100
targetPort: 3100
protocol: TCP
- name: grpc
port: 9095
targetPort: 9095
protocol: TCP
- name: memberlist
port: 7946
targetPort: 7946
protocol: TCP
selector:
app: loki
`, ns)
}
return manifest
}
// ==================== Loki 本地配置生成 (text/template) ====================
const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }}
server:
http_listen_port: 3100
grpc_listen_port: 9095
http_server_read_timeout: 3m
http_server_write_timeout: 3m
http_server_idle_timeout: 2m
grpc_server_max_recv_msg_size: 104857600
grpc_server_max_send_msg_size: 104857600
grpc_server_max_concurrent_streams: 1000
log_level: info
log_format: logfmt
common:
path_prefix: /var/lib/loki
storage:
filesystem:
chunks_directory: /var/lib/loki/chunks
rules_directory: /var/lib/loki/rules
replication_factor: {{ .ReplicationFactor }}
ring:
instance_addr: {{ .RingInstanceAddr }}
kvstore:
store: {{ .RingKVStore }}
{{- if .StorageConfigBlock }}
storage_config:
{{ .StorageConfigBlock }}
{{- end }}
{{- if .MemberlistBlock }}
{{ .MemberlistBlock }}
{{- end }}
ingester:
wal:
enabled: true
dir: /var/lib/loki/wal
flush_on_shutdown: true
replay_memory_ceiling: 1GB
lifecycler:
ring:
kvstore:
store: {{ .RingKVStore }}
replication_factor: {{ .ReplicationFactor }}
final_sleep: 0s
chunk_idle_period: 30m
chunk_retain_period: 30s
chunk_target_size: 1048576
chunk_encoding: snappy
max_chunk_age: 2h
autoforget_unhealthy: true
querier:
max_concurrent: 10
query_ingesters_within: 2h
query_scheduler:
max_outstanding_requests_per_tenant: 4096
compactor:
working_directory: /var/lib/loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
delete_request_store: {{ .CompactorStoreType }}
limits_config:
retention_period: {{ .RetentionPeriod }}h
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
per_stream_rate_limit: 3MB
per_stream_rate_limit_burst: 15MB
max_chunks_per_query: 2000000
max_query_series: 1000
max_query_parallelism: 32
max_entries_limit_per_query: 5000
max_label_name_length: 1024
max_label_value_length: 2048
max_label_names_per_series: 30
reject_old_samples: true
reject_old_samples_max_age: 168h
allow_structured_metadata: true
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: {{ .ObjectStore }}
schema: v13
index:
prefix: tsdb_index_
period: 24h
ruler:
storage:
type: {{ .RulerStoreType }}
{{- if .RulerStorageBlock }}
{{ .RulerStorageBlock }}
{{- end }}
{{- if eq .RulerStoreType "local" }}
local:
directory: /var/lib/loki/rules
{{- end }}
rule_path: /var/lib/loki/rules-temp
alertmanager_url: http://localhost:9093
ring:
kvstore:
store: {{ .RingKVStore }}
enable_api: true
enable_alertmanager_v2: true
frontend:
max_outstanding_per_tenant: 4096
compress_responses: true
frontend_worker: {}
chunk_store_config:
chunk_cache_config:
embedded_cache:
enabled: true
max_size_mb: 256
ttl: 1h
analytics:
reporting_enabled: false
`
type lokiConfigData struct {
AuthEnabled bool
ReplicationFactor int
RetentionPeriod int
StorageConfigBlock string
ObjectStore string
RulerStoreType string
CompactorStoreType string
RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址)
RingKVStore string // 单副本 inmemory;多副本 memberlist
MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置
RulerStorageBlock string // 对象存储时非空,注入 ruler.storage.s3/gcs/azure 嵌套配置
}
func (d *LokiDeployer) generateLokiConfig() string {
retentionHours := d.deployment.RetentionDays * 24
if retentionHours <= 0 {
retentionHours = 720 // 30 days default
}
storageType := d.effectiveStorageType()
storageBlock := d.buildStorageConfigBlock()
objectStore := "filesystem"
rulerStore := "local"
compactorStore := "filesystem"
switch storageType {
case "s3", "oss", "minio":
objectStore = "s3"
rulerStore = "s3"
compactorStore = "s3"
case "gcs":
objectStore = "gcs"
rulerStore = "gcs"
compactorStore = "gcs"
case "azure":
objectStore = "azure"
rulerStore = "azure"
compactorStore = "azure"
}
// Ring 拓扑:单副本用 inmemory + 127.0.0.1(无需跨实例通信);
// 多副本必须切 memberlist + 0.0.0.0,否则 ring 无法收敛、写入失败。
ringAddr := "127.0.0.1"
ringStore := "inmemory"
memberlistBlock := ""
if d.deployment.Replicas >= 2 {
ringAddr = "0.0.0.0"
ringStore = "memberlist"
ns := d.deployment.Namespace
if ns == "" {
ns = "log"
}
memberlistBlock = fmt.Sprintf(`memberlist:
join_members:
- loki-headless.%s.svc.cluster.local
bind_port: 7946
dead_node_reclaim_time: 30s
gossip_to_dead_nodes_time: 2m
left_ingesters_timeout: 5m`, ns)
}
data := lokiConfigData{
AuthEnabled: d.deployment.AuthEnabled,
ReplicationFactor: d.replicationFactor(),
RetentionPeriod: retentionHours,
StorageConfigBlock: storageBlock,
ObjectStore: objectStore,
RulerStoreType: rulerStore,
CompactorStoreType: compactorStore,
RingInstanceAddr: ringAddr,
RingKVStore: ringStore,
MemberlistBlock: memberlistBlock,
RulerStorageBlock: d.buildRulerStorageBlock(),
}
tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate)
if err != nil {
return d.generateLokiConfigFallback(retentionHours)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return d.generateLokiConfigFallback(retentionHours)
}
return buf.String()
}
func (d *LokiDeployer) buildStorageConfigBlock() string {
if d.deployment.StorageConfig == nil {
return ""
}
sc := d.deployment.StorageConfig
switch sc.StorageType {
case "s3":
endpoint := sc.Endpoint
if endpoint == "" {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
}
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` aws:
s3: s3://%s/%s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, insecure)
case "oss":
return fmt.Sprintf(` alibabacloud:
bucket: %s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s`, sc.Bucket, sc.Endpoint, sc.Region, sc.AccessKey, sc.SecretKey)
case "minio":
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` aws:
s3: s3://%s/%s
endpoint: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey, insecure)
case "gcs":
return fmt.Sprintf(` gcs:
bucket_name: %s`, sc.Bucket)
case "azure":
return fmt.Sprintf(` azure:
container_name: %s
account_name: %s
account_key: %s`, sc.Bucket, sc.AccessKey, sc.SecretKey)
default:
return ""
}
}
// buildRulerStorageBlock 生成 ruler.storage 下与存储类型同名的嵌套配置
// (Loki 3.x 的 ruler 存储客户端不回退读取 storage_config,缺少此块会以空 bucket 启动失败)
func (d *LokiDeployer) buildRulerStorageBlock() string {
if d.deployment.StorageConfig == nil {
return ""
}
sc := d.deployment.StorageConfig
switch sc.StorageType {
case "s3":
endpoint := sc.Endpoint
if endpoint == "" {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
}
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` s3:
s3: s3://%s/%s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, insecure)
case "oss":
return fmt.Sprintf(` s3:
s3: s3://%s/%s
endpoint: %s
access_key_id: %s
secret_access_key: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey)
case "minio":
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` s3:
s3: s3://%s/%s
endpoint: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey, insecure)
case "gcs":
return fmt.Sprintf(` gcs:
bucket_name: %s`, sc.Bucket)
case "azure":
return fmt.Sprintf(` azure:
container_name: %s
account_name: %s
account_key: %s`, sc.Bucket, sc.AccessKey, sc.SecretKey)
default:
return ""
}
}
// generateLokiConfigFallback 当模板解析失败时的降级配置
func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string {
return fmt.Sprintf(`auth_enabled: %v
server:
http_listen_port: 3100
grpc_listen_port: 9095
http_server_read_timeout: 3m
http_server_write_timeout: 3m
log_level: info
common:
path_prefix: /var/lib/loki
storage:
filesystem:
chunks_directory: /var/lib/loki/chunks
rules_directory: /var/lib/loki/rules
replication_factor: %d
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
ingester:
wal:
enabled: true
dir: /var/lib/loki/wal
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: %d
final_sleep: 0s
chunk_idle_period: 30m
max_chunk_age: 2h
querier:
max_concurrent: 10
compactor:
working_directory: /var/lib/loki/compactor
retention_enabled: true
retention_delete_delay: 2h
limits_config:
retention_period: %dh
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: tsdb_index_
period: 24h
analytics:
reporting_enabled: false
`, d.deployment.AuthEnabled, d.replicationFactor(), d.replicationFactor(), retentionHours)
}
// ==================== 辅助函数 ====================
func (d *LokiDeployer) effectiveVersion() string {
if d.deployment.Version != "" {
return d.deployment.Version
}
return "3.0.0"
}
func (d *LokiDeployer) effectiveNamespace() string {
if d.deployment.Namespace != "" {
return d.deployment.Namespace
}
return "log"
}
func (d *LokiDeployer) effectiveReleaseName() string {
if d.deployment.Name != "" {
return d.deployment.Name
}
return "loki"
}
func (d *LokiDeployer) effectiveStorageType() string {
if d.deployment.StorageConfig != nil && d.deployment.StorageConfig.StorageType != "" {
return d.deployment.StorageConfig.StorageType
}
return "filesystem"
}
func (d *LokiDeployer) checkVersionCompatibility() error {
version := d.effectiveVersion()
parts := strings.Split(version, ".")
if len(parts) < 1 {
return fmt.Errorf("cannot parse version: %s", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Errorf("cannot parse major version: %s", version)
}
if major < 2 {
return fmt.Errorf("Loki version %s is too old, recommend 2.8+ or 3.x", version)
}
return nil
}
func (d *LokiDeployer) log(level, message, detail string) {
if d.logFunc != nil {
d.logFunc(level, message, detail)
}
}
// ==================== 清理 / 卸载 ====================
func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
// 同环境部署的 Grafana 一并清理
if deployment.GrafanaEnabled {
CleanupGrafana(ctx, deployment)
}
if deployment.Mode == "k8s-helm" && deployment.Cluster != nil {
kubeconfig, err := resolveKubeconfig(deployment.Cluster)
if err != nil {
return
}
kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600)
if err != nil {
return
}
defer cleanup()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile))
ns := deployment.Namespace
if ns == "" {
ns = "log"
}
name := deployment.Name
if name == "" {
name = "loki"
}
cmd := exec.CommandContext(ctx, "helm", "uninstall", name, "--namespace", ns)
cmd.Env = env
cmd.CombinedOutput()
// 清理 PVC(Helm 不会自动删除 PVC)
cleanupPVC := exec.CommandContext(ctx, "kubectl", "delete", "pvc",
"-l", "app.kubernetes.io/name=loki", "-n", ns, "--ignore-not-found=true")
cleanupPVC.Env = env
cleanupPVC.CombinedOutput()
}
if deployment.Mode == "k8s-yaml" && deployment.Cluster != nil {
kubeconfig, err := resolveKubeconfig(deployment.Cluster)
if err != nil {
return
}
kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600)
if err != nil {
return
}
defer cleanup()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile))
ns := deployment.Namespace
if ns == "" {
ns = "log"
}
// 删除所有相关资源
resources := []string{
"statefulset/loki",
"service/loki",
"configmap/loki-config",
"persistentvolumeclaim/loki-data",
}
for _, res := range resources {
cmd := exec.CommandContext(ctx, "kubectl", "delete", res, "-n", ns, "--ignore-not-found=true")
cmd.Env = env
cmd.CombinedOutput()
}
}
if deployment.Mode == "host-binary" && deployment.Host != nil {
host := deployment.Host
client, _ := NewSSHClientWithAuth(HostSSHAuth(host))
if client != nil {
defer client.Close()
client.Execute(ctx, "systemctl stop loki 2>/dev/null || true")
client.Execute(ctx, "systemctl disable loki 2>/dev/null || true")
client.Execute(ctx, "rm -f /etc/systemd/system/loki.service /usr/local/bin/loki")
client.Execute(ctx, "systemctl daemon-reload")
}
}
}
// DecryptCred 解密凭证
func DecryptCred(encrypted string) (string, error) {
if strings.HasPrefix(encrypted, "ENC:") {
return crypto.Decrypt(encrypted[4:])
}
return encrypted, nil
}
// ==================== 通用工具函数 ====================
// writeTempFile 安全地创建临时文件,返回文件路径和清理函数
func writeTempFile(pattern, content string, perm os.FileMode) (string, func(), error) {
f, err := os.CreateTemp("", pattern)
if err != nil {
return "", nil, fmt.Errorf("create temp file failed: %w", err)
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(f.Name())
return "", nil, fmt.Errorf("write temp file failed: %w", err)
}
f.Close()
if perm > 0 {
os.Chmod(f.Name(), perm)
}
cleanup := func() { os.Remove(f.Name()) }
return f.Name(), cleanup, nil
}
// indentYAML 对 YAML 内容进行缩进(用于嵌入 ConfigMap)
func indentYAML(s string, spaces int) string {
pad := strings.Repeat(" ", spaces)
lines := strings.Split(s, "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
}