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:
MiFaZhan
2026-10-22 16:14:24 +08:00
commit 5732bf3384
33 changed files with 2676 additions and 0 deletions
+275
View File
@@ -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}))
}