chore: 初始开源发布
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
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/render"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
archiveDir, environment string
|
||||
stateDir string
|
||||
pollInterval time.Duration
|
||||
store state.Store
|
||||
client Sender
|
||||
logger *slog.Logger
|
||||
// warned 记录已经报过错的归档,避免坏文件每轮扫描都刷一条日志。
|
||||
// 键为「路径 + mtime + 大小」:文件一旦被重写就会重新告警。
|
||||
warned map[string]struct{}
|
||||
}
|
||||
|
||||
// Sender 是发送端抽象:*feishu.Client 满足它,测试可注入替身。
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, card []byte) error
|
||||
SendText(ctx context.Context, text string) error
|
||||
}
|
||||
|
||||
func New(archiveDir, environment string, pollInterval time.Duration, store state.Store, client Sender, logger *slog.Logger) Service {
|
||||
if logger == nil {
|
||||
logger = DefaultLogger()
|
||||
}
|
||||
return Service{archiveDir: archiveDir, environment: environment, stateDir: store.Dir(), pollInterval: pollInterval, store: store, client: client, logger: logger, warned: map[string]struct{}{}}
|
||||
}
|
||||
func (s Service) Render(path string) (string, error) {
|
||||
a, err := archive.Load(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return render.Text(a, s.environment), nil
|
||||
}
|
||||
|
||||
func (s Service) SendFile(ctx context.Context, path string, force bool) error {
|
||||
if err := s.store.Prepare(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.process(ctx, path, force)
|
||||
}
|
||||
|
||||
func (s Service) runFiles() ([]string, error) {
|
||||
files, err := filepath.Glob(filepath.Join(s.archiveDir, "run-*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// skipKey 生成归档文件的「内容版本」标识,用于去重告警。
|
||||
// 读不到元数据时退化为路径本身,宁可少报警也不要刷屏。
|
||||
func skipKey(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return fmt.Sprintf("%s|%d|%d", path, info.ModTime().UnixNano(), info.Size())
|
||||
}
|
||||
|
||||
// archiveError 区分「这个归档永远不会成功」与「这次发送碰上了临时故障」。
|
||||
// 前者(格式不对、旧版本、rounds 为空)是常态,只该报一次;
|
||||
// 后者(飞书超时、鉴权失败、写状态失败)必须每轮重试并持续可见,
|
||||
// 否则一次网络抖动就会被去重逻辑吞掉,通知静默丢失。
|
||||
type archiveError struct {
|
||||
err error
|
||||
permanent bool
|
||||
}
|
||||
|
||||
func (e *archiveError) Error() string { return e.err.Error() }
|
||||
func (e *archiveError) Unwrap() error { return e.err }
|
||||
|
||||
// permanentErr 标记「重试也不会好」的错误。
|
||||
func permanentErr(err error) error { return &archiveError{err: err, permanent: true} }
|
||||
|
||||
// isPermanent 判断错误是否属于「归档本身无效」。
|
||||
func isPermanent(err error) bool {
|
||||
var ae *archiveError
|
||||
return errors.As(err, &ae) && ae.permanent
|
||||
}
|
||||
|
||||
// warnOnce 对同一个归档文件只记一次「跳过」日志。
|
||||
// 只有永久性错误才去重;临时错误原样返回给调用方,保持每轮可见。
|
||||
func (s Service) warnOnce(path string, err error) {
|
||||
if !isPermanent(err) {
|
||||
s.logger.Error("process archive failed", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
key := skipKey(path)
|
||||
if s.warned != nil {
|
||||
if _, seen := s.warned[key]; seen {
|
||||
return
|
||||
}
|
||||
s.warned[key] = struct{}{}
|
||||
}
|
||||
s.logger.Error("skip archive", "path", path, "error", err)
|
||||
}
|
||||
|
||||
// Scan 扫描归档目录并发送尚未通知过的 Run。
|
||||
//
|
||||
// 单个归档出错(旧格式、写了一半、权限不足)只跳过并记日志:
|
||||
// 过去这里直接 return,一个坏文件会挡住后面所有归档,通知会静默丢失。
|
||||
// 坏文件是常态(例如 manual 部分同步的 Run 天然没有 rounds),所以只记一次。
|
||||
func (s Service) Scan(ctx context.Context) error {
|
||||
if err := s.store.Prepare(); err != nil {
|
||||
return err
|
||||
}
|
||||
files, err := s.runFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range files {
|
||||
if err := s.process(ctx, path, false); err != nil {
|
||||
s.warnOnce(path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s Service) Watch(ctx context.Context) error {
|
||||
if s.pollInterval <= 0 {
|
||||
s.pollInterval = 10 * time.Second
|
||||
}
|
||||
var emptySince time.Time
|
||||
for {
|
||||
h := s.Health()
|
||||
if h.Degraded {
|
||||
// 目录已被宿主删除等硬故障:告警节流状态落盘,避免退出重启后
|
||||
// 把同一次故障反复推送成飞书骚扰。
|
||||
now := time.Now()
|
||||
st := loadAlertState(s.stateDir)
|
||||
if st.shouldAlert(h.Reason, now, DegradedAlertInterval) {
|
||||
s.logger.Error("archive directory is not usable", "path", s.archiveDir, "reason", h.Reason, "entries", h.Entries)
|
||||
s.alertMount(ctx, h.Reason, st.ExitCount+1)
|
||||
st.LastDegradedAlert = now
|
||||
st.LastDegradedReason = h.Reason
|
||||
if err := saveAlertState(s.stateDir, st); err != nil {
|
||||
s.logger.Error("save alert state failed", "error", err)
|
||||
}
|
||||
}
|
||||
if ExitOnDegraded {
|
||||
// 退出并交给 restart policy 重新拉起:重启会重新解析 bind mount,
|
||||
// 从而重新挂载到宿主当前的目录(详见 ExitOnDegraded 注释)。
|
||||
st.ExitCount++
|
||||
if err := saveAlertState(s.stateDir, st); err != nil {
|
||||
s.logger.Error("save alert state failed", "error", err)
|
||||
}
|
||||
if st.ExitCount > DegradedMaxExits {
|
||||
// 已经反复重启仍未恢复(例如宿主把目录删了且不再重建):
|
||||
// 继续退出只会变成重启风暴,改为留在原地持续观测+告警。
|
||||
s.logger.Error("archive mount still unusable after repeated restarts, staying up to keep reporting",
|
||||
"path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount)
|
||||
} else {
|
||||
// 等一会再退:restart 策略是立即拉起的,立刻退出会造成几秒一次的崩溃循环。
|
||||
s.logger.Error("exit to rebind archive mount", "path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount, "delay", DegradedExitDelay)
|
||||
timer := time.NewTimer(DegradedExitDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
return fmt.Errorf("archive directory unusable, restarting to rebind mount: %s", h.Reason)
|
||||
}
|
||||
}
|
||||
} else if h.Entries == 0 {
|
||||
if emptySince.IsZero() {
|
||||
emptySince = time.Now()
|
||||
}
|
||||
} else {
|
||||
emptySince = time.Time{}
|
||||
}
|
||||
|
||||
if err := s.Scan(ctx); err != nil {
|
||||
s.logger.Error("scan failed", "error", err)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(s.Interval(emptySince))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alert 在飞书客户端可用时发一条文本告警;失败只记日志,不影响主循环。
|
||||
func (s Service) alert(ctx context.Context, title, reason string) {
|
||||
if s.client == nil {
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("[iotlcm-bot][%s] %s: %s", s.environment, title, reason)
|
||||
if err := s.client.SendText(ctx, text); err != nil {
|
||||
s.logger.Error("send alert failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// alertMount 发送「归档目录不可用」告警卡片。
|
||||
//
|
||||
// 用卡片而非纯文本:text 消息在飞书里是灰底等宽块,长错误串会把气泡撑得很宽;
|
||||
// 卡片带红色 header 与分区字段,一眼能看出是故障,且与同步结果卡片风格一致。
|
||||
func (s Service) alertMount(ctx context.Context, reason string, exitCount int) {
|
||||
if s.client == nil {
|
||||
return
|
||||
}
|
||||
summary := "正在重启以重新挂载"
|
||||
if exitCount > DegradedMaxExits {
|
||||
summary = "反复重启仍未恢复,已改为持续监控"
|
||||
}
|
||||
card, err := render.AlertCard(render.Alert{
|
||||
Environment: s.environment,
|
||||
Title: "归档目录不可用",
|
||||
Summary: summary,
|
||||
Reason: render.FriendlyReason(reason),
|
||||
ExitCount: exitCount,
|
||||
Path: s.archiveDir,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("render alert card failed", "error", err)
|
||||
return
|
||||
}
|
||||
if err := s.client.Send(ctx, card); err != nil {
|
||||
s.logger.Error("send alert failed", "error", err)
|
||||
}
|
||||
}
|
||||
func (s Service) process(ctx context.Context, path string, force bool) error {
|
||||
a, err := archive.Load(path)
|
||||
if err != nil {
|
||||
// 归档本身无效(格式/版本/rounds 为空):重试也不会好。
|
||||
return permanentErr(err)
|
||||
}
|
||||
if !force {
|
||||
sent, err := s.store.Has(a.Run.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sent {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
card, err := render.Card(a, s.environment)
|
||||
if err != nil {
|
||||
return permanentErr(err)
|
||||
}
|
||||
if s.client == nil {
|
||||
return fmt.Errorf("feishu client is not configured")
|
||||
}
|
||||
if err := s.client.Send(ctx, card); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Mark(a.Run.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("run notification sent", "runId", a.Run.RunID, "path", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
}
|
||||
Reference in New Issue
Block a user