5732bf3384
iotlcm-bot:独立飞书应用机器人(读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片)。
- Go module:git.5iot.com/iotlcm/iotlcm-bot → git.5iot.com/iotlcm-public/iotlcm-bot
- 飞书凭据占位:appId/appSecret/chatId → ${FEISHU_APP_ID}/${FEISHU_APP_SECRET}/${FEISHU_CHAT_ID}
- 移除内部资产:CI 触发器
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → scratch)
- README 重写(命令/配置/挂载契约/常见问题),去掉内部环境路径
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes
108 lines
3.3 KiB
Go
108 lines
3.3 KiB
Go
package notify
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAlertStateSurvivesRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
now := time.Now()
|
|
|
|
// 首次:没有任何记录 → 应该告警
|
|
first := loadAlertState(dir)
|
|
if !first.shouldAlert("dir gone", now, DegradedAlertInterval) {
|
|
t.Fatal("first alert must be sent")
|
|
}
|
|
first.LastDegradedAlert = now
|
|
first.LastDegradedReason = "dir gone"
|
|
if err := saveAlertState(dir, first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 模拟进程退出后重启:重新从磁盘加载
|
|
reloaded := loadAlertState(dir)
|
|
if reloaded.shouldAlert("dir gone", now.Add(time.Second), DegradedAlertInterval) {
|
|
t.Fatal("after restart the same failure must NOT alert again immediately (this is the alert-flood bug)")
|
|
}
|
|
// 间隔到了才允许再报
|
|
if !reloaded.shouldAlert("dir gone", now.Add(DegradedAlertInterval+time.Second), DegradedAlertInterval) {
|
|
t.Fatal("alert must be sent again after the interval elapses")
|
|
}
|
|
// 故障原因变化时立即告警
|
|
if !reloaded.shouldAlert("permission denied", now.Add(time.Second), DegradedAlertInterval) {
|
|
t.Fatal("a changed failure reason must alert immediately")
|
|
}
|
|
}
|
|
|
|
func TestExitCountIsPersistedAndIncremented(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st := loadAlertState(dir)
|
|
if st.ExitCount != 0 {
|
|
t.Fatalf("fresh state exitCount=%d want 0", st.ExitCount)
|
|
}
|
|
st.ExitCount++
|
|
if err := saveAlertState(dir, st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 重启后计数必须延续,否则无法识别「反复重启」
|
|
if got := loadAlertState(dir).ExitCount; got != 1 {
|
|
t.Fatalf("exitCount after reload=%d want 1", got)
|
|
}
|
|
}
|
|
|
|
// 关键回归:挂载失效 → 退出 → 重启后,同一次故障只能推一条飞书。
|
|
func TestWatchAlertsOnceAcrossRestarts(t *testing.T) {
|
|
shortenExitDelay(t)
|
|
if !ExitOnDegraded {
|
|
t.Skip("ExitOnDegraded disabled")
|
|
}
|
|
stateDir := t.TempDir()
|
|
archiveDir := filepath.Join(t.TempDir(), "gone")
|
|
fake := &fakeFeishu{}
|
|
newSvc := func() Service {
|
|
return Service{
|
|
archiveDir: archiveDir,
|
|
environment: "test",
|
|
stateDir: stateDir,
|
|
pollInterval: 10 * time.Millisecond,
|
|
store: newTestStore(stateDir),
|
|
client: fake,
|
|
logger: discardLogger(),
|
|
}
|
|
}
|
|
// 第一次运行:应告警并退出
|
|
if err := newSvc().Watch(context.Background()); err == nil {
|
|
t.Fatal("Watch must exit when the mount is degraded")
|
|
}
|
|
if fake.sent != 1 {
|
|
t.Fatalf("first run must send exactly one alert, sent=%d", fake.sent)
|
|
}
|
|
// 模拟被 restart 策略拉起:新进程、新内存,但 state 目录相同
|
|
if err := newSvc().Watch(context.Background()); err == nil {
|
|
t.Fatal("Watch must exit again")
|
|
}
|
|
if fake.sent != 1 {
|
|
t.Fatalf("restart must NOT resend the same alert (alert flood), sent=%d", fake.sent)
|
|
}
|
|
// 退出计数应累计到 2
|
|
if got := loadAlertState(stateDir).ExitCount; got != 2 {
|
|
t.Fatalf("exitCount=%d want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestAlertStateMissingDirIsNotFatal(t *testing.T) {
|
|
// 状态目录不可写时不应影响告警逻辑(退化为每次都报,但至少不崩)
|
|
st := loadAlertState("")
|
|
if !st.shouldAlert("x", time.Now(), DegradedAlertInterval) {
|
|
t.Fatal("empty state must allow alerting")
|
|
}
|
|
if err := saveAlertState("", st); err != nil {
|
|
t.Fatalf("saving to empty state dir must be a no-op, got %v", err)
|
|
}
|
|
_ = os.Remove("nonexistent")
|
|
}
|