=== 一、保留策略弹框重设计(RetentionDialog)===
旧版问题:
1. 主输入是 YAML textarea,用户必须会写 `limits_config.retention_period: 30d`
2. 三个按钮:取消 / 仅保存 / 保存并应用 —— 「仅保存」只改部署记录不重载 Loki,
今天已经造成一次真实故障:DB 记录 7 天、运行时 ConfigMap 是 24h,配置漂移
3. 「查看完整配置」是独立按钮 + 独立状态,多一次点击
4. 硬编码提示 `retention_delete_delay: 2h`,而实际已改成 10m,属于误导
新版:
- 主输入改为**保留天数数字框**(1–3650),YAML 由前端拼,用户不用写
- 快捷预设 1/7/14/30/90,实时显示「= 720 小时」
- >7 天给出存储成本提示
- 完整运行 config 改成 `<details>` 折叠,展开即自动加载(去掉独立按钮)
- **按钮只剩 2 个:取消 / 保存并应用**
- 删掉硬编码的 compactor 提示块
=== 二、日志分析页加命名空间筛选 ===
根因:analysis 页写死 namespace: '',等价 `{namespace=~".+"}` —— 一次分析扫全集群
(实测约 370 GB/天),5 条 count_over_time 跑 30s 都出不来,页面永远转圈。
- 新增命名空间下拉(从 Loki label values 拉取,localStorage 记忆)
- 默认取业务命名空间 astralx(其次列表首个),不再默认全集群
- 「全部命名空间(较慢)」降级为显式选项
- 命名空间未就绪时先显示 loading,避免先发一次全集群查询
- 5 条查询改**分批(每批 2 条)**执行 + 429 自动重试一次
=== 三、后端并发/超时调整 ===
- 重查询并发上限 2 → 4,且拿不到令牌时**排队最多 10s**再拒绝
(原来立即 429,导致分析页 5 条并发里有 3 条直接失败)
- maxLogQueryLimit 1000 → 5000,与前端下拉完全对齐(原来前端选 5000 会被静默截断)
=== 四、过期 chunk 真正删除(关键修复)===
实测发现:索引表已从 COS 删除,但 chunk 对象仍残留 21,354 个 / 24.8 GB,
最老的 45 小时前 —— 因为 Loki 默认 `retention_delete_delay` = **2h**,
标记后要等 2 小时才真正删 chunk。
- 配置显式设置 `compactor.retention_delete_delay: 10m`
- 已用 `loki -verify-config` 校验(config is valid)
- 删除前后对比:
对象数 46,536 → 23,934;占用 53.61 GB → 27.25 GB
超期对象 21,354 个 / 24.8 GB → **0 个**
桶内最老对象 45.2h → 24.4h(= 保留期 1 天)
- deployment_config_test 同步调整:retention_delete_delay 从"禁止出现"移入"必须存在"
镜像:lghost/lokistack:latest + v0.1 = sha256:b4d98e016bca
146 lines
6.1 KiB
Go
146 lines
6.1 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"io"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
"lokistack/models"
|
||
)
|
||
|
||
func TestRedactLokiYAML(t *testing.T) {
|
||
source := "storage_config:\n aws:\n bucketnames: loki\n access_key_id: ENC:private-access\n secret_access_key: private-secret\nlimits_config:\n retention_period: 720h\n"
|
||
got, err := redactLokiYAML(source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, secret := range []string{"private-access", "private-secret", "ENC:"} {
|
||
if strings.Contains(got, secret) {
|
||
t.Fatalf("sensitive value remains in config: %s", secret)
|
||
}
|
||
}
|
||
for _, want := range []string{"bucketnames: loki", "retention_period: 720h", "access_key_id: '********'"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Fatalf("missing %q in sanitized config: %s", want, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMinIOConfigUsesBucketNameNotPathPrefix(t *testing.T) {
|
||
d := NewLokiDeployer(&models.LokiDeployment{StorageConfig: &models.StorageConfig{
|
||
StorageType: "minio", Bucket: "logs-bucket", PathPrefix: "wrong-prefix", Endpoint: "http://minio:9000",
|
||
AccessKey: "key", SecretKey: "secret", Insecure: true,
|
||
}}, nil)
|
||
config := d.generateLokiConfig()
|
||
if count := strings.Count(config, "bucketnames: logs-bucket"); count != 2 {
|
||
t.Fatalf("bucket name should appear in storage and ruler config; got %d", count)
|
||
}
|
||
if strings.Contains(config, "wrong-prefix") || strings.Contains(config, "s3://") {
|
||
t.Fatalf("invalid S3 URL or path prefix in generated config")
|
||
}
|
||
}
|
||
|
||
func TestDeployRejectsUndecryptedStorageCredential(t *testing.T) {
|
||
d := NewLokiDeployer(&models.LokiDeployment{StorageConfig: &models.StorageConfig{
|
||
StorageType: "minio", AccessKey: "ENC:unavailable", SecretKey: "secret",
|
||
}}, nil)
|
||
if err := d.Deploy(context.Background()); err == nil || !strings.Contains(err.Error(), "存储凭证解密失败") {
|
||
t.Fatalf("expected credential failure, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestGeneratedLokiConfigKeepsRequiredSettingsWithoutDefaults(t *testing.T) {
|
||
d := NewLokiDeployer(&models.LokiDeployment{RetentionDays: 90, Replicas: 1, StorageConfig: &models.StorageConfig{
|
||
StorageType: "minio", Bucket: "logs", Endpoint: "http://minio:9000",
|
||
AccessKey: "key", SecretKey: "secret", Insecure: true,
|
||
}}, nil)
|
||
config := d.generateLokiConfig()
|
||
var parsed map[string]interface{}
|
||
if err := yaml.Unmarshal([]byte(config), &parsed); err != nil {
|
||
t.Fatalf("invalid Loki YAML: %v", err)
|
||
}
|
||
// 注意 delete_request_store 用的是 filesystem 而不是 s3:
|
||
// loki_config.go 里对 s3/oss/minio/cos/obs 刻意把 compactor 的删除请求存到
|
||
// 本地盘(见该处注释),避免各云 S3 兼容实现差异导致 compactor 启动失败。
|
||
// loki_object_store_test.go 断言的也是 filesystem —— 两处必须一致。
|
||
for _, required := range []string{"retention_period: 2160h", "retention_enabled: true", "delete_request_store: filesystem", "retention_delete_delay: 10m", "bucketnames: logs", "store: tsdb", "schema: v13"} {
|
||
if !strings.Contains(config, required) {
|
||
t.Fatalf("required setting %q missing", required)
|
||
}
|
||
}
|
||
// 注意:retention_delete_delay 已从"不该出现"清单移除 —— 现在**有意**配置为 10m。
|
||
// 默认 2h 会让对象存储里多留 2 小时数据(实测桶内超期对象一度占 24.6GB),
|
||
// 预发环境用 10m 既保留安全窗口又不白付存储费。
|
||
for _, defaultSetting := range []string{"http_listen_port:", "grpc_listen_port:", "chunk_idle_period:", "compaction_interval:", "max_entries_limit_per_query:", "frontend_worker:", "chunk_store_config:", "autoforget_unhealthy:"} {
|
||
if strings.Contains(config, defaultSetting) {
|
||
t.Fatalf("unnecessary setting %q remained", defaultSetting)
|
||
}
|
||
}
|
||
d.deployment.Replicas = 2
|
||
if !strings.Contains(d.generateLokiConfig(), "autoforget_unhealthy: true") {
|
||
t.Fatal("multi-replica ring recovery setting missing")
|
||
}
|
||
}
|
||
|
||
func TestK8sManifestKeepsStorageCredentialsInSecret(t *testing.T) {
|
||
d := NewLokiDeployer(&models.LokiDeployment{Mode: "k8s-yaml", RetentionDays: 60, Replicas: 1, StorageConfig: &models.StorageConfig{
|
||
StorageType: "minio", Bucket: "loki", Endpoint: "http://minio:9000",
|
||
AccessKey: "test-access", SecretKey: "test-secret", Insecure: true,
|
||
}}, nil)
|
||
manifest := d.generateManifest("log")
|
||
decoder := yaml.NewDecoder(strings.NewReader(manifest))
|
||
var configYAML string
|
||
var secret map[string]interface{}
|
||
for {
|
||
var doc map[string]interface{}
|
||
if err := decoder.Decode(&doc); err != nil {
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
t.Fatalf("invalid manifest YAML: %v", err)
|
||
}
|
||
switch doc["kind"] {
|
||
case "ConfigMap":
|
||
configYAML = doc["data"].(map[string]interface{})["loki.yaml"].(string)
|
||
case "Secret":
|
||
secret = doc["data"].(map[string]interface{})
|
||
}
|
||
}
|
||
if configYAML == "" || secret == nil {
|
||
t.Fatal("ConfigMap or Secret missing")
|
||
}
|
||
if strings.Contains(configYAML, "test-access") || strings.Contains(configYAML, "test-secret") {
|
||
t.Fatal("storage credential leaked into ConfigMap")
|
||
}
|
||
for _, marker := range []string{"${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}", "retention_period: 1440h", "retention_enabled: true"} {
|
||
if !strings.Contains(configYAML, marker) {
|
||
t.Fatalf("required Loki setting %q missing", marker)
|
||
}
|
||
}
|
||
if !strings.Contains(manifest, "-config.expand-env=true") || !strings.Contains(manifest, "secretKeyRef:") {
|
||
t.Fatal("StatefulSet does not reference storage Secret")
|
||
}
|
||
for key, want := range map[string]string{"accessKey": "test-access", "secretKey": "test-secret"} {
|
||
decoded, err := base64.StdEncoding.DecodeString(secret[key].(string))
|
||
if err != nil || string(decoded) != want {
|
||
t.Fatalf("Secret %s data mismatch", key)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestK8sManifestChangesPodTemplateWhenRetentionChanges(t *testing.T) {
|
||
deployment := &models.LokiDeployment{RetentionDays: 30, Replicas: 1}
|
||
d := NewLokiDeployer(deployment, nil)
|
||
checksum := regexp.MustCompile(`checksum/loki-config: "[a-f0-9]{64}"`)
|
||
first := checksum.FindString(d.generateManifest("log"))
|
||
deployment.RetentionDays = 70
|
||
second := checksum.FindString(d.generateManifest("log"))
|
||
if first == "" || second == "" || first == second {
|
||
t.Fatalf("Pod config checksum must change with retention: %q -> %q", first, second)
|
||
}
|
||
}
|