mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # AGENTS.md # backend/docs/docs.go # backend/docs/swagger.json # backend/docs/swagger.yaml # frontend/components/providers/title-updater.tsx # frontend/messages/fragments/admin.en.json # frontend/messages/fragments/admin.zh-CN.json # frontend/proxy.ts
This commit is contained in:
@@ -1,108 +1,217 @@
|
||||
---
|
||||
name: "cache-framework"
|
||||
description: "Wavelet 项目专用:当新增或修改基于 Cordis 插件的业务缓存、ctx.Cache() / contracts.CacheService 访问、三层读路径(RAM L1 + Redis L2 + DB L3)、多节点 Pub/Sub 失效同步时必须使用。"
|
||||
description: "Wavelet 项目专用:当新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、或评估高频读是否应接入缓存时必须使用。本技能说明系统标准缓存框架、参考实现、禁止写法与分布式一致性要求。"
|
||||
---
|
||||
|
||||
# 系统三层缓存框架与开发规范 (Cordis 插件化架构)
|
||||
# 系统三层缓存框架
|
||||
|
||||
本技能指导 Wavelet 在 Cordis 架构下,如何使用平台统一提供的三层缓存服务(`ctx.Cache()` 与 `contracts.CacheService`)进行高性能缓存读写与分布式失效同步。
|
||||
开始前阅读根目录 `AGENTS.md`(含 **Skill 关联索引**)。Wavelet 标准读路径为 **本地 RAM → Redis → PostgreSQL**(由快到慢),不是 DB 优先。
|
||||
|
||||
---
|
||||
详细性能背景见 `docs/PERFORMANCE.md`。
|
||||
|
||||
## 1. 三层读路径与标准契约
|
||||
## 关联 Skill
|
||||
|
||||
Wavelet 标准读路径为 **本地 RAM (L1) → Redis (L2) → Database (L3)**(由快到慢):
|
||||
| 关联 | 何时一并阅读 |
|
||||
| :--- | :--- |
|
||||
| [database-migration](../database-migration/SKILL.md) | 缓存对象对应新表/列/索引,或 seed 变更 |
|
||||
| [new-setting](../new-setting/SKILL.md) | 系统配置类缓存(`GetSystemConfigByKey`、`ListSystemConfigsByKeys`) |
|
||||
| [file-upload](../file-upload/SKILL.md) | 上传元数据 `upload:meta:{id}`、ingest/remove/cleanup 失效钩子 |
|
||||
| [clickhouse-batchwriter](../clickhouse-batchwriter/SKILL.md) | 分析写入走 batchwriter,**不要**用本技能模式缓存 CH flush 队列 |
|
||||
| [new-api](../new-api/SKILL.md) | 在 Handler 层接入 `GetXxxCached` 或评估高频读 |
|
||||
| [new-async-task](../new-async-task/SKILL.md) | Worker/定时任务变更数据后必须 `Invalidate*`(如 `system:cleanup`) |
|
||||
|
||||
## 标准模式(金标准)
|
||||
|
||||
参考:`internal/repository/system_config_cache.go` + `GetSystemConfigByKey` / `ListSystemConfigsByKeys`。
|
||||
|
||||
| 层级 | 技术 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| **L1 本地** | `pkg/cache/ram` (Otter) | 进程内纳秒级极速读取,抗最高频热点流量 |
|
||||
| **L2 共享** | Redis 序列化缓存 | 跨节点共享,具备 TTL 与防击穿保护 |
|
||||
| **L3 权威** | 关系型数据库 (PostgreSQL / SQLite) | 唯一权威数据源 |
|
||||
| L1 本地 | `pkg/cache/ram`(Otter v2) | 进程内热数据,最低延迟 |
|
||||
| L2 共享 | Redis `db.GetJSON` / `SetJSON` / `HSetJSON` + `db.PrefixedKey` | 跨节点共享,带 TTL 或写穿 |
|
||||
| L3 权威 | PostgreSQL via `db.DB(ctx)` | 唯一数据源 |
|
||||
|
||||
### 标准接口契约 (`contracts.CacheService`)
|
||||
### 读路径模板
|
||||
|
||||
```go
|
||||
type CacheService interface {
|
||||
// Get 从缓存获取并反序列化至 target,若不存在返回 ErrCacheMiss
|
||||
Get(ctx context.Context, key string, target any) error
|
||||
func GetThingCached(ctx context.Context, key string) (Thing, error) {
|
||||
ensureThingCacheListener() // 订阅 pub/sub,仅 sync.Once
|
||||
|
||||
// Set 存储对象至缓存并设置 TTL
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
|
||||
// Delete 彻底移除缓存(清空本地 RAM、删除 Redis 并广播 Pub/Sub 通知全集群清空 RAM)
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// GetOrSet 优先读缓存,若未命中则执行 loader 回源加载并自动回写
|
||||
GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error
|
||||
|
||||
// Invalidate 是 Delete 的语义别名
|
||||
Invalidate(ctx context.Context, key string) error
|
||||
if v, ok := thingRAM.GetIfPresent(key); ok {
|
||||
return cloneThing(v), nil
|
||||
}
|
||||
if db.Redis != nil {
|
||||
var v Thing
|
||||
if err := db.GetJSON(ctx, redisKey(key), &v); err == nil {
|
||||
thingRAM.Set(key, cloneThing(v))
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
v, err := loadThingFromDB(ctx, key)
|
||||
if err != nil {
|
||||
return Thing{}, err
|
||||
}
|
||||
populateThingCache(ctx, v) // 回写 RAM + Redis
|
||||
return v, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### 写穿(populate)
|
||||
|
||||
## 2. 业务使用标准范式
|
||||
|
||||
### 2.1 高性能读穿透 (`GetOrSet`)
|
||||
|
||||
业务 Service 推荐优先使用 `GetOrSet`,框架底层自动完成 L1/L2 穿透、回写及并发防击穿:
|
||||
DB miss 或业务创建成功后,**必须**回写上层:
|
||||
|
||||
```go
|
||||
func (s *OrderService) GetOrderWithCache(ctx context.Context, orderID string) (*Order, error) {
|
||||
var order Order
|
||||
cacheKey := "order:" + orderID
|
||||
|
||||
err := s.cache.GetOrSet(ctx, cacheKey, &order, 10*time.Minute, func() (any, error) {
|
||||
// Cache Miss: 执行 DB 回源查询
|
||||
var dbOrder Order
|
||||
if err := s.db.WithContext(ctx).First(&dbOrder, "id = ?", orderID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dbOrder, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
func populateThingCache(ctx context.Context, v Thing) {
|
||||
thingRAM.Set(v.Key, cloneThing(v))
|
||||
if db.Redis != nil {
|
||||
_ = db.SetJSON(ctx, redisKey(v.Key), v, cacheTTL)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 数据变更与失效广播 (`Invalidate` / `Delete`)
|
||||
### 失效(Invalidate)— 分布式必做三步
|
||||
|
||||
凡涉及数据创建、修改、软删除、状态变更的入口(**包含 HTTP Handler、后台 Worker 任务、定时清理任务**),必须调用缓存失效:
|
||||
数据变更(Admin 更新、软删除、状态迁移)时:
|
||||
|
||||
1. **本机 RAM** — `thingRAM.Invalidate(key)` 或 `InvalidateAll()`
|
||||
2. **Redis** — `Del` / `HDel` 对应 key
|
||||
3. **pub/sub 广播** — 通知**其他节点**清除 RAM(Redis 已由写节点清掉)
|
||||
|
||||
```go
|
||||
func (s *OrderService) UpdateOrderStatus(ctx context.Context, orderID string, newStatus string) error {
|
||||
// 1. 更新数据库权威数据
|
||||
if err := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", orderID).Update("status", newStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 广播失效缓存(自动清除本机 L1、删除 Redis L2,并向集群广播 Pub/Sub 消息清空其他节点 L1)
|
||||
return s.cache.Invalidate(ctx, "order:"+orderID)
|
||||
func InvalidateThingCache(ctx context.Context, key string) error {
|
||||
ensureThingCacheListener()
|
||||
thingRAM.Invalidate(key)
|
||||
if db.Redis != nil {
|
||||
if err := db.Redis.Del(ctx, db.PrefixedKey(redisKey(key))).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
publishThingRAMInvalidation(ctx, key) // 只广播 RAM 失效
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### pub/sub 监听模板
|
||||
|
||||
## 3. 核心规则与禁止写法 (Guardrails)
|
||||
```go
|
||||
const thingInvalidationChannel = "domain:thing_invalidation"
|
||||
|
||||
1. **严禁自研本地 map 缓存**:
|
||||
- 严禁在插件内编写 `sync.RWMutex + map[string]Xxx` 的裸内存缓存,无法感知多节点数据变更,必然引发多机脏读。
|
||||
2. **写路径必须全覆盖失效**:
|
||||
- 不仅在 API 修改时失效,后台 Worker、定时任务执行数据清理或变更时,必须同步触发 `cache.Invalidate`。
|
||||
3. **Key 命名空间规范**:
|
||||
- 缓存 Key 必须带插件命名空间前缀(如 `order:meta:{id}`、`auth:session:{token}`)。
|
||||
4. **不可在业务高频读接口中绕过缓存直查 DB**。
|
||||
func startThingCacheInvalidationListener() {
|
||||
if db.Redis == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
pubsub := db.Redis.Subscribe(context.Background(), thingInvalidationChannel)
|
||||
defer func() { _ = pubsub.Close() }()
|
||||
for msg := range pubsub.Channel() {
|
||||
// 解析 payload,Invalidate RAM;勿重复 Del Redis
|
||||
thingRAM.Invalidate(parsedKey)
|
||||
}
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
- 使用 `sync.Once` 启动监听;**`ensureListener` 必须在 `db.Redis == nil` 时直接 return,不可消费 Once**(否则测试或 Redis 晚初始化时监听器永不启动)。
|
||||
- 测试可提供 `StopThingCacheListener` + 重置 `Once`(参考 `StopUploadMetaCacheListener`、`StopAuthSourceCacheListener`)。
|
||||
- 其他节点收到消息后**只清 RAM**,不再删 Redis。
|
||||
|
||||
## 4. 质量与测试验证
|
||||
## 现有实现速查
|
||||
|
||||
| 域 | 文件 | L1 | L2 | pub/sub |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| 系统配置 | `repository/system_config_cache.go` | `pkg/cache/store` | ❌ 无 Redis 缓存 | `system:config_broadcast` (别名 `system:config_invalidation`) ✅ |
|
||||
| CAPTCHA 运行时 | `apps/cap/runtime_settings.go` | atomic.Pointer | (借配置 Redis) | 订阅 `system:config_invalidation` ✅ |
|
||||
| 上传元数据 | `apps/upload/cache/meta_cache.go` | Otter | Redis JSON | `upload:meta_invalidation` ✅ |
|
||||
| 上传访问白名单 | `apps/upload/cache/access_cache.go` | 进程内 TTL | (借配置读路径) | `upload:file_access_invalidation` ✅ |
|
||||
| Auth Source | `repository/auth_source_cache.go` | Otter | Redis JSON | `oauth:auth_source_invalidation` ✅ |
|
||||
| OAuth 用户/Token | `apps/oauth/cache.go` | 自研 map | Redis JSON | ❌ 无 pub/sub(历史债) |
|
||||
| 推送渠道 | `repository/push_channel.go` | 无 | Redis JSON | ❌ 仅 Redis Del |
|
||||
| Storage 驱动 | `internal/infra/objectstore/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ |
|
||||
|
||||
## 新增缓存工作流
|
||||
|
||||
1. **判定是否需要缓存**:高频读、低变更、可容忍短暂 TTL;写路径必须能统一失效。
|
||||
2. **选型 L1**:优先 `pkg/cache/ram.MustNew`;**禁止**自研 `map+mutex+TTL`,除非有充分理由并文档说明。
|
||||
3. **选型 L2**:小对象 `SetJSON`;配置类多条目用 Redis Hash(`HSetJSON`)。
|
||||
4. **定义 Redis key**:小写蛇形,带业务前缀(`upload:meta:{id}`);统一 `db.PrefixedKey`。
|
||||
5. **实现 Invalidate + pub/sub**:凡多实例部署可读的 RAM 缓存**必须**有失效广播。
|
||||
6. **挂载变更钩子**:在所有 DB 变更入口调用 Invalidate(含 Worker/定时任务,不只 HTTP Handler)。
|
||||
7. **测试**:
|
||||
- RAM hit / Redis hit / DB fallback
|
||||
- Invalidate 清 L1+L2
|
||||
- pub/sub 触发他机 RAM 失效(可用 miniredis Publish 模拟)
|
||||
- `Reset*RAMCacheForTest` 仅清本机 RAM
|
||||
8. 运行 `go test` 相关包 + `make code-check`。
|
||||
|
||||
## 变更钩子清单(上传元数据示例)
|
||||
|
||||
| 入口 | 动作 |
|
||||
| :--- | :--- |
|
||||
| `ingest.persistUploadRecord` 创建成功 | `SetUploadMetaCache` |
|
||||
| `ingest.Remove` / `RemoveOwned` | `InvalidateUploadMetaCache` |
|
||||
| `task/cleanup.go` 软删除 pending 文件 | `InvalidateUploadMetaCache` |
|
||||
| 直接 `repository.SoftDeleteUpload` | **禁止** — 必须走 `upload.Remove` |
|
||||
|
||||
## 禁止写法
|
||||
|
||||
```go
|
||||
// ❌ 自研 L1,与 pkg/cache/ram 重复
|
||||
var mu sync.RWMutex
|
||||
var items = map[uint64]entry{}
|
||||
|
||||
// ❌ 只清本机 RAM + Redis,无 pub/sub(多节点 RAM 脏读)
|
||||
func Invalidate(ctx context.Context, id uint64) {
|
||||
localDelete(id)
|
||||
redis.Del(...)
|
||||
}
|
||||
|
||||
// ❌ DB 变更后忘记 Worker 路径
|
||||
// cleanup 任务删了 upload 行,但未 InvalidateUploadMetaCache
|
||||
|
||||
// ❌ 在 Handler 里直接查 DB,绕过已有 GetXxxCached
|
||||
|
||||
// ❌ Redis key 不用 PrefixedKey(多环境共 Redis 时冲突)
|
||||
|
||||
// ❌ 在 init() 里启动 pub/sub 监听 — 与 bootstrap 规范冲突;用 sync.Once 懒启动
|
||||
```
|
||||
|
||||
## 特殊场景
|
||||
|
||||
### 敏感字段(ClientSecret)
|
||||
|
||||
模型 `json:"-"` 时,Redis DTO 用独立 `*RedisRecord` struct 显式序列化字段(见 `auth_source_cache.go`)。
|
||||
|
||||
### 批量读配置
|
||||
|
||||
批量接口必须与单 key 一致走 Redis(`ListSystemConfigsByKeys` 在 RAM miss 后逐 key `HGetJSON`,再 DB `IN`)。
|
||||
|
||||
### 仅进程内、短 TTL、配置衍生
|
||||
|
||||
可用进程内快照 + 订阅上游 pub/sub(`access_cache.go`、`cap/runtime_settings.go`),不必强行 Redis L2。
|
||||
|
||||
### OAuth 用户/Token
|
||||
|
||||
沿用 `oauth/cache.go`;新增逻辑调用 `SetCachedUser` / `SetCachedToken` 预热,变更调用 `InvalidateCachedUser` / `InvalidateCachedToken`。
|
||||
|
||||
## 验证清单
|
||||
|
||||
```bash
|
||||
make format
|
||||
go test ./internal/repository/... ./internal/apps/upload/cache/...
|
||||
make code-check
|
||||
go test ./plugins/...
|
||||
```
|
||||
```
|
||||
|
||||
- [ ] L1 使用 `pkg/cache/ram`(或已文档化的例外)
|
||||
- [ ] 读路径:RAM → Redis → DB
|
||||
- [ ] 写穿 populate 在 DB load / 创建成功后
|
||||
- [ ] Invalidate:RAM + Redis + Publish
|
||||
- [ ] `ensureListener` + pub/sub 清他机 RAM
|
||||
- [ ] 所有变更入口(含 Worker)已挂钩
|
||||
- [ ] 测试含 Invalidate 与 pub/sub
|
||||
|
||||
## 相关文件
|
||||
|
||||
- L1 引擎:`pkg/cache/ram/cache.go`
|
||||
- DB/Redis 助手:`internal/infra/persistence/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`)
|
||||
- 金标准:`internal/repository/system_config_cache.go`
|
||||
- 上传元数据:`internal/apps/upload/cache/meta_cache.go`
|
||||
- Auth Source:`internal/repository/auth_source_cache.go`
|
||||
- 性能文档:`docs/PERFORMANCE.md`
|
||||
@@ -62,24 +62,26 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
| 域 | 表 | 写入路径 |
|
||||
| :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` |
|
||||
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` → `logstore.Active` |
|
||||
| 可观测时序 | `of_node_metric_snapshots` 等 | `openflare/chwriter` 分表 writer + 进程内短 TTL 去重 → `logstore.Active` |
|
||||
|
||||
**不要**把不同日志域并入同一 channel。新日志表先按 `logstore` skill 判定,再为本域建独立 writer。
|
||||
**不要**把 audit、access log、observability 并入同一 channel。
|
||||
|
||||
## 新增 ClickHouse 写入工作流
|
||||
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。
|
||||
2. **Goose DDL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
|
||||
3. **Repository**:实现 `BatchInsertX(ctx, []analyticsmodel.X) error`:
|
||||
- `len(items)==0` 直接返回
|
||||
- `db.ChConn == nil` 返回明确错误
|
||||
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
|
||||
- `len(items)==0` 直接返回
|
||||
- `db.ChConn == nil` 返回明确错误
|
||||
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/`):
|
||||
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
|
||||
- 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill)
|
||||
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
|
||||
5. **测试**:
|
||||
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
|
||||
- batchwriter:`go test ./internal/infra/persistence/batchwriter`
|
||||
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
|
||||
- batchwriter:`go test ./internal/infra/persistence/batchwriter`
|
||||
6. 运行 `make code-check`;有 API 变更时 `make swagger`。
|
||||
|
||||
## 背压与丢弃策略
|
||||
@@ -87,7 +89,8 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
| 场景 | 推荐策略 |
|
||||
| :--- | :--- |
|
||||
| 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) |
|
||||
| 可丢弃的高频日志 | 队列满 → `WithDropHandler` 记 warn;不阻塞请求 |
|
||||
| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 |
|
||||
| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 |
|
||||
|
||||
## 禁止写法
|
||||
|
||||
@@ -152,6 +155,9 @@ make code-check
|
||||
- 框架:`internal/infra/persistence/batchwriter/{config,writer,errs}.go`
|
||||
- 连接:`internal/infra/persistence/clickhouse.go`
|
||||
- 审计写入:`internal/apps/risk_control/logics.go`
|
||||
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go`
|
||||
- 日志抽象:`internal/repository/logstore`
|
||||
- 节点访问日志 CH 实现:`internal/repository/analytics/node_access_log_writer.go`
|
||||
- 可观测 CH 实现:`internal/repository/analytics/node_observability_writer.go`
|
||||
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
|
||||
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
|
||||
@@ -1,188 +1,138 @@
|
||||
---
|
||||
name: "database-migration"
|
||||
description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、插件自包含 Goose SQL 迁移、embed.FS 注册、PG/SQLite 双方言支持或 ClickHouse 分析库 DDL 时必须使用。"
|
||||
description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/infra/persistence/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/infra/persistence/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。"
|
||||
---
|
||||
|
||||
# 数据库独立迁移与表结构开发规范 (Cordis 插件化架构)
|
||||
# Wavelet 数据库升级操作指南
|
||||
|
||||
本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行数据库表结构设计、Goose SQL 迁移与插件嵌入式注册的唯一指导规范。
|
||||
Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/infra/persistence/migrator.Migrate()`,SQL 文件嵌入在二进制中。
|
||||
|
||||
---
|
||||
## 基本规则
|
||||
|
||||
## 1. 核心架构:插件自包含迁移 (Self-Contained Migrations)
|
||||
|
||||
在 Cordis 架构中,**彻底告别集中式单体大迁移目录**。
|
||||
每个插件在自身包内维护专属的 `migrations/` 目录,通过 Go 语言内置 `//go:embed` 打包为嵌入式文件系统,并在 `Apply(ctx *core.Context)` 时通过微内核扩展点 `ctx.Migrations().Register(...)` 自主注入。
|
||||
|
||||
```
|
||||
backend/plugins/domain/order/
|
||||
├── plugin.go
|
||||
├── models.go
|
||||
└── migrations/
|
||||
└── 00001_initial.sql ← 每个插件仅一个初始迁移文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 插件迁移代码集成标准
|
||||
|
||||
### 步骤 1:在插件内嵌入并注册迁移
|
||||
|
||||
```go
|
||||
package order
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var orderMigrations embed.FS
|
||||
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 注册本插件的专属迁移(系统启动时由微内核统一收集并按版本执行)
|
||||
ctx.Migrations().Register("order", orderMigrations)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2:编写 Goose SQL 脚本 (`migrations/00001_initial.sql`)
|
||||
|
||||
每个插件只需维护一个 `00001_initial.sql`,包含其全部建表语句与种子数据。
|
||||
- SQL 迁移文件放在:
|
||||
- `internal/infra/persistence/migrator/goose/postgres/`
|
||||
- `internal/infra/persistence/migrator/goose/sqlite/`
|
||||
- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。
|
||||
- 迁移文件使用 goose SQL 标记:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS w_orders (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_orders_user_id ON w_orders(user_id);
|
||||
|
||||
-- 种子数据
|
||||
INSERT INTO w_orders (id, user_id, amount, status)
|
||||
VALUES ('init_001', 'system', 0, 'completed')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
-- +goose StatementEnd
|
||||
...
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS w_orders;
|
||||
-- +goose StatementEnd
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
- 不要把表结构、默认系统配置、默认模板、默认管理员初始化写回 Go 代码。
|
||||
- 编辑表结构(DDL)和插入表数据(DML/Seed)不要放在同一个 SQL 文件里,必须分成两个独立的 SQL 文件完成(例如,先通过一个文件修改表结构,再通过下一个递增版本号的文件插入/初始化数据)。
|
||||
- 插入定时任务(schedules 表数据)时绝对不能指定 `id`,必须依靠数据库自增(Identity 或 AUTOINCREMENT)自动分配,防止与用户手动或后续插入的定时任务产生 ID 冲突。
|
||||
- 不要添加物理外键;关系字段使用显式索引。
|
||||
- 数据库默认值应匹配 Go model 零值或业务兜底值。
|
||||
- 系统配置仍然保存字符串值;布尔值写 `"true"` / `"false"`,数字写十进制字符串,复杂结构写合法 JSON 字符串。
|
||||
|
||||
## 3. 版本管理与升级机制
|
||||
## 新增迁移流程
|
||||
|
||||
### 3.1 版本表结构
|
||||
1. 先确认涉及的 Go model、读写路径和前端/接口消费方。
|
||||
2. 选择下一个递增版本号,格式建议 `YYYYMMDDNNNN`,例如:
|
||||
|
||||
所有插件共享一张 `w_schema_versions` 表,以 `plugin_id` 为区分:
|
||||
|
||||
```sql
|
||||
w_schema_versions (
|
||||
plugin_id VARCHAR(64) NOT NULL, -- 如 "auth", "user", "admin"
|
||||
version_id BIGINT NOT NULL, -- 迁移文件版本号 (00001 → 1)
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (plugin_id, version_id)
|
||||
)
|
||||
```text
|
||||
202606090002_add_example_column.sql
|
||||
```
|
||||
|
||||
### 3.2 升级判定逻辑
|
||||
|
||||
启动时,`gooseEngine` 遍历每个已注册的插件:
|
||||
|
||||
```
|
||||
for each plugin:
|
||||
1. 查询 w_schema_versions WHERE plugin_id = 'auth'
|
||||
2. 获取该插件的最大 version_id
|
||||
3. 读取插件 migration/ 目录下的所有 .sql 文件
|
||||
4. 如果存在 version_id 更大的文件 → 执行升级
|
||||
5. 如果全部已应用 → 跳过
|
||||
```
|
||||
|
||||
### 3.3 什么情况下升级?
|
||||
|
||||
| 场景 | 例子 | 是否升级 |
|
||||
|------|------|---------|
|
||||
| 首次部署,插件第一次运行 | auth 插件,表不存在 | ✅ 执行 `00001_initial.sql` |
|
||||
| 第二次启动,无变化 | 文件未变,版本已记录 | ❌ 跳过 |
|
||||
| 追加新迁移文件 | 新增 `00002_add_index.sql` | ✅ 执行 `00002_*` |
|
||||
| 移除一个插件 | 该插件不再注册 | ❌ 其记录在表中被忽略 |
|
||||
| 新增一个插件 | 新插件有 `00001_initial.sql` | ✅ 执行 |
|
||||
|
||||
### 3.4 查看全局迁移状态
|
||||
|
||||
```sql
|
||||
SELECT * FROM w_schema_versions ORDER BY plugin_id, version_id;
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
plugin_id | version_id | applied_at
|
||||
---------------------+------------+---------------------------
|
||||
admin | 1 | 2026-08-28 10:00:00+00
|
||||
auth | 1 | 2026-08-28 10:00:00+00
|
||||
user | 1 | 2026-08-28 10:00:00+00
|
||||
upload | 1 | 2026-08-28 10:00:00+00
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心设计与防线原则 (Guardrails)
|
||||
|
||||
1. **表单一所有者原则 (Single Owner Principle)**:
|
||||
- 每张数据表归属且仅归属于一个所有者插件(如 `w_orders` 归 `order` 插件)。
|
||||
- **严禁**插件 B 跨包编写 SQL 直接读写插件 A 拥有的表;必须通过插件 A 暴露的 `contracts` 接口或事件总线进行交互。
|
||||
|
||||
2. **表名前缀规范**:
|
||||
- 所有表名必须带有前缀(如 `w_orders`、`w_auth_users`),杜绝跨插件表名冲突。
|
||||
|
||||
3. **单文件初始迁移**:
|
||||
- 每个插件只维护一个 `00001_initial.sql`,包含该插件所有表的建表语句与初始种子数据。
|
||||
- 未来如需追加 DDL,新增 `00002_xxx.sql`,Goose 会根据 `w_schema_versions` 判断增量执行。
|
||||
|
||||
4. **禁止物理外键**:
|
||||
- 关系字段统一显式建立单列或联合索引,禁止在数据库中创建物理外键约束。
|
||||
|
||||
5. **双方言兼容性(PostgreSQL & SQLite)**:
|
||||
- 自增主键:PG 用 `BIGSERIAL`,SQLite 用 `INTEGER PRIMARY KEY AUTOINCREMENT`。
|
||||
- 时间类型:PG 用 `TIMESTAMPTZ`,SQLite 用 `DATETIME`。
|
||||
- JSON 类型:PG 用 `JSONB`,SQLite 用 `JSON` 或 `TEXT`。
|
||||
|
||||
6. **幂等性要求**:
|
||||
- 所有 `CREATE TABLE` 必须使用 `IF NOT EXISTS`。
|
||||
- 所有 `INSERT` 种子数据必须使用 `ON CONFLICT DO NOTHING`。
|
||||
- 所有 `ALTER TABLE ADD COLUMN` 必须使用 `IF NOT EXISTS`(如果数据库方言支持)。
|
||||
|
||||
---
|
||||
|
||||
## 5. ClickHouse 分析库迁移规则 (辅助 OLAP)
|
||||
|
||||
ClickHouse 作为辅助 OLAP 分析存储,采用独立迁移通道:
|
||||
- 迁移文件位于专属目录 `migrations-clickhouse/`(仅单方言 DDL,不创建 SQLite 镜像)。
|
||||
- 日志/分析用途表必须同时在关系型主库建回落表并接入 `logstore` 门面。
|
||||
- 分析表高频写入统一接入 `batchwriter` 进行异步批量刷盘。
|
||||
|
||||
---
|
||||
|
||||
## 6. 质量与验证门禁
|
||||
3. 在 PostgreSQL 和 SQLite 目录各新增同名 SQL 文件。
|
||||
4. 写 `Up`:
|
||||
- 表结构变更使用 SQL DDL。
|
||||
- 初始化/seed 数据使用 SQL `INSERT`。
|
||||
- 需要幂等时使用 `IF NOT EXISTS` 或 `ON CONFLICT ... DO NOTHING`。
|
||||
5. 写 `Down`:
|
||||
- 能安全回滚的结构变更写反向 DDL。
|
||||
- seed 数据按 key/name 等稳定标识删除。
|
||||
6. 如果变更 API handler,运行 `make swagger`。
|
||||
7. 至少运行:
|
||||
|
||||
```bash
|
||||
make format
|
||||
go test ./internal/infra/persistence/migrator
|
||||
go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config
|
||||
make code-check
|
||||
go test ./plugins/...
|
||||
```
|
||||
|
||||
验证迁移注册完整性:
|
||||
## 方言注意事项
|
||||
|
||||
- PostgreSQL 自增主键用 `BIGSERIAL`;SQLite 自增主键用 `INTEGER PRIMARY KEY AUTOINCREMENT`。
|
||||
- PostgreSQL 时间类型优先 `TIMESTAMPTZ`;SQLite 使用 `DATETIME`。
|
||||
- PostgreSQL JSON 字段用 `JSONB`;SQLite 用 `JSON` 或 `TEXT`。
|
||||
- 两个方言目录的字段名、索引名、seed 数据语义必须保持一致。
|
||||
|
||||
## 修改默认系统配置
|
||||
|
||||
- 新增或调整系统配置 seed 时,更新两个方言的 SQL 文件。
|
||||
- `visibility` 使用常量语义:`0` 不公开,`1` 通过 `/api/v1/config/public` 返回。
|
||||
- 公共配置 API 直接返回所有 `visibility = 1` 的配置键值,不要在 handler 中重新硬编码 key 列表。
|
||||
|
||||
## 验证重点
|
||||
|
||||
- goose 能在空库上完整执行。
|
||||
- `system_configs`、默认 `admin`、内置模板能按预期初始化。
|
||||
- 新增表/列与 Go model 的列名、类型和默认值兼容。
|
||||
- 前端或接口消费的公共配置值仍按字符串解析。
|
||||
|
||||
## ClickHouse 分析库(辅助 OLAP)
|
||||
|
||||
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
|
||||
|
||||
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
|
||||
- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。
|
||||
|
||||
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
|
||||
|
||||
### 目录与职责
|
||||
|
||||
| 路径 | 职责 |
|
||||
| :--- | :--- |
|
||||
| `internal/infra/persistence/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
|
||||
| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 |
|
||||
| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) |
|
||||
| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`ChDB` GORM 查询) |
|
||||
|
||||
### 迁移入口与版本表
|
||||
|
||||
- 入口:`migrator.MigrateClickHouse()`,在 `cmd/root.go` 的 `PreRun` 中于 `migrator.Migrate()` 之后调用。
|
||||
- 仅当 `clickhouse.enabled: true` 时执行;禁用时直接跳过(见 `TestMigrateClickHouseSkipsWhenDisabled`)。
|
||||
- 版本表:`goose_clickhouse_version`,与主库 `goose_db_version` **分离**,互不影响。
|
||||
- 方言:仅 ClickHouse,**无** SQLite 镜像目录。
|
||||
|
||||
### ClickHouse 迁移规则
|
||||
|
||||
1. **DDL 只写 goose SQL**:`CREATE TABLE IF NOT EXISTS ...`,禁止 GORM `AutoMigrate`、禁止在 repository 或 handler 中建表。
|
||||
2. **无事务**:ClickHouse 不支持 goose 事务包装;每个 `Up`/`Down` 语句独立提交。
|
||||
3. **幂等 Up**:表用 `IF NOT EXISTS`;`Down` 用 `DROP TABLE IF EXISTS`。
|
||||
4. **Down 谨慎**:MergeTree 等引擎上 `DROP TABLE` 会立即删除数据,生产环境通常只前滚;仅在开发/测试需要回滚时编写 `Down`。
|
||||
5. **DDL 与 DML 分离**:与主库相同,表结构变更与数据初始化分文件、分版本号;分析表通常无 seed,批量写入由 repository 在运行时完成。
|
||||
6. **引擎与排序键**:在 SQL 中显式声明 `ENGINE`、`PARTITION BY`、`ORDER BY` 等,与查询模式对齐(例如按 `created_at` 分区)。
|
||||
7. **禁止重复 DDL**:不要在 `support-files/`、`apps` 初始化逻辑或 `repository/analytics` 中复制建表语句。
|
||||
|
||||
### 新增分析表工作流
|
||||
|
||||
按以下顺序落地,避免列名或类型漂移:
|
||||
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
|
||||
2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
|
||||
3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。
|
||||
|
||||
### ClickHouse 验证
|
||||
|
||||
至少运行:
|
||||
|
||||
```bash
|
||||
# 检查每个有 migrations/ 目录的插件是否同时有 go:embed + Register()
|
||||
grep -rn 'go:embed.*migrations' backend/plugins/domain/*/plugin.go backend/plugins/drivers/*/plugin.go
|
||||
grep -rn 'Migrations()\.Register' backend/plugins/domain/*/plugin.go backend/plugins/drivers/*/plugin.go
|
||||
```
|
||||
go test ./internal/infra/persistence/migrator
|
||||
go test ./internal/repository/analytics
|
||||
make code-check
|
||||
```
|
||||
|
||||
验证重点:
|
||||
|
||||
- goose 能在空 ClickHouse 实例上完整执行 `Up`。
|
||||
- `internal/model/analytics` 列名、类型与 goose SQL 一致。
|
||||
- repository 读写路径不依赖 handler 内联 SQL。
|
||||
- `clickhouse.enabled: false` 时启动不报错、不执行迁移。
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Wavelet 项目专用:当业务需要上传文件、读取已上
|
||||
|
||||
# 存储引擎与文件上传开发规范
|
||||
|
||||
本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](../../../AGENTS.md),遵守项目级核心规则。
|
||||
本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。
|
||||
|
||||
---
|
||||
|
||||
@@ -83,8 +83,8 @@ invoice.FilePath = "uploads/2026/01/02/123.pdf"
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/pkg/model"
|
||||
"OpenFlare/internal/apps/upload"
|
||||
"OpenFlare/internal/model"
|
||||
)
|
||||
|
||||
func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) {
|
||||
@@ -217,7 +217,7 @@ upload.RebuildUploadStats(ctx) // 从 w_uploads 全量重建统计
|
||||
- **禁止**在源码目录硬编码 `uploads/test` 路径;本地文件测试用 `t.TempDir()` 或 mock backend
|
||||
- 覆盖:三种 Policy、Remove 后统计归零、ReadOnly 拒绝写入
|
||||
|
||||
参考:`internal/apps/upload/ingest/ingest_test.go`
|
||||
参考:[internal/apps/upload/ingest/ingest_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/upload/ingest/ingest_test.go)
|
||||
|
||||
### Handler 回归
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: go-packages
|
||||
description: Use when creating Go packages, organizing imports, managing dependencies, or deciding how to structure Go code into packages. Also use when starting a new Go project or splitting a growing codebase into packages, even if the user doesn't explicitly ask about package organization. Does not cover naming individual identifiers (see go-naming).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 包和 Import
|
||||
|
||||
> **本技能不适用的场景**:对于包内单个标识符的命名,参见 [go-naming](../go-naming/SKILL.md)。对于单文件中函数的组织,参见 [go-functions](../go-functions/SKILL.md)。对于强制执行 import 规则的 linter 配置,参见 [go-linting](../go-linting/SKILL.md)。
|
||||
|
||||
## 包组织
|
||||
|
||||
### 避免 Util 包
|
||||
|
||||
包名应描述包提供的内容。避免使用 `util`、`helper`、`common` 等泛化名称——它们会模糊含义并导致 import 冲突。
|
||||
|
||||
```go
|
||||
// 好:有意义的包名
|
||||
db := spannertest.NewDatabaseFromFile(...)
|
||||
_, err := f.Seek(0, io.SeekStart)
|
||||
|
||||
// 不好:模糊的名称遮蔽含义
|
||||
db := test.NewDatabaseFromFile(...)
|
||||
_, err := f.Seek(0, common.SeekStart)
|
||||
```
|
||||
|
||||
泛化名称可以作为名称的*一部分*(例如 `stringutil`),但不应成为整个包名。
|
||||
|
||||
### Package Size
|
||||
|
||||
| 问题 | 操作 |
|
||||
|------|------|
|
||||
| 你能用一句话描述它的用途吗? | 不能 → 按职责拆分 |
|
||||
| 文件中从未共享未导出的符号? | 这些文件可以是独立的包 |
|
||||
| 不同的用户群体使用不同部分? | 按用户边界拆分 |
|
||||
| Godoc 页面过于庞大? | 拆分以提高可发现性 |
|
||||
|
||||
**不要拆分**的原因仅仅是文件很长、创建只有单一类型的包,或会产生循环依赖。
|
||||
|
||||
> 在决定是否拆分或合并包、组织包内文件或构建 CLI 程序时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。使用
|
||||
[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) 自动管理。
|
||||
|
||||
```go
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/foo/bar"
|
||||
"rsc.io/goversion/version"
|
||||
)
|
||||
```
|
||||
|
||||
**快速规则:**
|
||||
|
||||
| 规则 | 指导 |
|
||||
|------|------|
|
||||
| 分组 | 标准库优先,然后是外部包。扩展分组:标准库 → 其他 → proto → 副作用 |
|
||||
| 重命名 | 除非冲突,否则避免重命名。重命名最本地的 import。Proto 包加 `pb` 后缀 |
|
||||
| 空白 import(`import _`) | 仅在 `main` 包或测试中使用 |
|
||||
| 点 import(`import .`) | 永不使用,除非用于循环依赖的测试文件 |
|
||||
|
||||
> 在组织扩展分组的 import、重命名 proto 包或决定使用空白/点 import 时,阅读 [references/IMPORTS.md](references/IMPORTS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 避免 init()
|
||||
|
||||
尽可能避免 `init()`。当不可避免时,它必须是:
|
||||
|
||||
1. 完全确定性的
|
||||
2. 不依赖于其他 `init()` 的执行顺序
|
||||
3. 不依赖环境状态(环境变量、工作目录、参数)
|
||||
4. 不进行 I/O(文件系统、网络、系统调用)
|
||||
|
||||
**可接受的使用场景**:无法用单个赋值完成的复杂表达式、可插拔钩子(例如 `database/sql` 方言)、确定性预计算。
|
||||
|
||||
> 在需要将 init() 重构为显式函数或理解可接受的 init() 使用场景时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Main 中的退出
|
||||
|
||||
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应返回 error。
|
||||
|
||||
**原因**:不明显的控制流、不可测试、`defer` 语句被跳过。
|
||||
|
||||
**最佳实践**:使用 `run()` 模式——将逻辑提取到
|
||||
`func run() error` 中,在 `main()` 中调用并使用单一退出点:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 在实现 run() 模式、构建 CLI 子命令或选择 flag 命名约定时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 命令行 Flag
|
||||
|
||||
> **建议**:仅在 `package main` 中定义 flag。
|
||||
|
||||
- Flag 名称使用 `snake_case`:`--output_dir` 而非 `--outputDir`
|
||||
- 库应通过参数接收配置,而非直接读取 flag——
|
||||
这使它们可测试且可复用
|
||||
- 优先使用标准 `flag` 包;仅在需要 POSIX 约定
|
||||
(双破折号、单字符快捷方式)时使用 `pflag`
|
||||
|
||||
```go
|
||||
// 好:Flag 在 main 中定义,作为参数传递给库
|
||||
func main() {
|
||||
outputDir := flag.String("output_dir", ".", "directory for output files")
|
||||
flag.Parse()
|
||||
if err := mylib.Generate(*outputDir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **包命名**:在选择包名、避免名称重复或命名导出符号时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **跨包的错误处理**:在使用 `%w` vs `%v` 在包边界包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **Import linting**:在配置 goimports local-prefixes 或强制执行 import 分组时,参见 [go-linting](../go-linting/SKILL.md)
|
||||
- **全局状态**:在用显式初始化替换 `init()` 或避免可变全局变量时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
@@ -0,0 +1,110 @@
|
||||
# Import 组织
|
||||
|
||||
Go import 组织的详细规则和示例。
|
||||
|
||||
## Import 分组
|
||||
|
||||
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。
|
||||
|
||||
**最小分组(Uber):** 标准库,然后其他所有。
|
||||
|
||||
**扩展分组(Google):** 标准库 → 其他 → protocol buffers → 副作用。
|
||||
|
||||
```go
|
||||
// 好:标准库与外部包分开
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:完整分组,包含 proto 和副作用
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/dsnet/compress/flate"
|
||||
"golang.org/x/text/encoding"
|
||||
|
||||
foopb "myproj/foo/proto/proto"
|
||||
|
||||
_ "myproj/rpc/protocols/dial"
|
||||
)
|
||||
```
|
||||
|
||||
## Import 重命名
|
||||
|
||||
避免重命名 import,除非为了避免名称冲突;好的包名不需要重命名。
|
||||
在发生冲突时,**优先重命名最本地的或项目特定的 import**。
|
||||
|
||||
**必须重命名:** 与其他 import 冲突、生成的 protocol buffer 包
|
||||
(删除下划线,添加 `pb` 后缀)。
|
||||
|
||||
**可以重命名:** 无意义的名称(例如 `v1`)、与本地变量冲突。
|
||||
|
||||
```go
|
||||
// 好:Proto 包用 pb 后缀重命名
|
||||
import (
|
||||
foosvcpb "path/to/package/foo_service_go_proto"
|
||||
)
|
||||
|
||||
// 好:当需要 url 变量时使用 urlpkg
|
||||
import (
|
||||
urlpkg "net/url"
|
||||
)
|
||||
|
||||
func parseEndpoint(url string) (*urlpkg.URL, error) {
|
||||
return urlpkg.Parse(url)
|
||||
}
|
||||
```
|
||||
|
||||
## 空白 Import(`import _`)
|
||||
|
||||
仅为副作用而导入的包(使用 `import _ "pkg"`)
|
||||
应仅在程序的主包(main)或需要它们的测试中导入。
|
||||
|
||||
```go
|
||||
// 好:在主包中使用空白 import
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "time/tzdata"
|
||||
_ "image/jpeg"
|
||||
)
|
||||
```
|
||||
|
||||
## 点 Import(`import .`)
|
||||
|
||||
**不要**使用点 import。它们使程序难以阅读,因为不清楚
|
||||
`Quux` 这样的名称是当前包中的顶层标识符还是导入包中的。
|
||||
|
||||
**例外:** `import .` 形式在由于循环依赖而无法成为被测试包的一部分的测试文件中可能有用:
|
||||
|
||||
```go
|
||||
package foo_test
|
||||
|
||||
import (
|
||||
"bar/testutil" // 也导入了 "foo"
|
||||
. "foo"
|
||||
)
|
||||
```
|
||||
|
||||
在这种情况下,测试文件不能是 `foo` 包,因为它使用了
|
||||
`bar/testutil`,而后者导入了 `foo`。因此 `import .` 形式让文件
|
||||
假装是 `foo` 包的一部分,即使实际上不是。
|
||||
|
||||
**除了这一种情况外,不要在程序中使用 `import .`。**
|
||||
|
||||
```go
|
||||
// 不好:点 import 隐藏了来源
|
||||
import . "foo"
|
||||
var myThing = Bar() // Bar 来自哪里?
|
||||
|
||||
// 好:显式限定
|
||||
import "foo"
|
||||
var myThing = foo.Bar()
|
||||
```
|
||||
@@ -0,0 +1,214 @@
|
||||
# 包大小、程序结构和 CLI
|
||||
|
||||
关于包拆分、避免 init()、run() 模式和 CLI 结构的详细指南。
|
||||
|
||||
## 何时拆分包
|
||||
|
||||
```
|
||||
包是否变得太大?
|
||||
├─ 你能用一句话描述它的用途吗?
|
||||
│ ├─ 不能 → 按职责拆分
|
||||
│ └─ 能 → 保留,但检查以下内容
|
||||
├─ 包中的文件是否从未导入彼此的未导出符号?
|
||||
│ └─ 是 → 这些文件可以是独立的包
|
||||
├─ 包是否有不同的用户群体使用不同部分?
|
||||
│ └─ 是 → 按用户边界拆分
|
||||
└─ godoc 页面是否过于庞大?
|
||||
└─ 是 → 拆分以提高可发现性
|
||||
```
|
||||
|
||||
### 何时不应拆分
|
||||
|
||||
- 不要仅因为文件很长就拆分——聚焦的包中的大文件是可以的
|
||||
- 不要创建只包含一个类型或函数的包
|
||||
- 如果会产生循环依赖则不要拆分
|
||||
- 避免将内部辅助工具拆分到 `util` 或 `internal/helpers` 包中
|
||||
|
||||
### 何时合并包
|
||||
|
||||
- 如果客户端代码很可能需要两个类型交互,保持它们在一起
|
||||
- 如果类型有紧密耦合的实现
|
||||
- 如果用户需要同时导入两个包才能有意义地使用其中任何一个
|
||||
|
||||
### 文件组织
|
||||
|
||||
Go 中没有"一个类型一个文件"的惯例。文件应该足够聚焦以便知道哪个文件包含什么内容,且足够小以便轻松查找。
|
||||
|
||||
---
|
||||
|
||||
## 避免 init()
|
||||
|
||||
优先使用显式函数而非 `init()`:
|
||||
|
||||
```go
|
||||
// 不好:init() 带有 I/O 和环境依赖
|
||||
var _config Config
|
||||
|
||||
func init() {
|
||||
cwd, _ := os.Getwd()
|
||||
raw, _ := os.ReadFile(path.Join(cwd, "config.yaml"))
|
||||
yaml.Unmarshal(raw, &_config)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:用于加载配置的显式函数
|
||||
func loadConfig() (Config, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path.Join(cwd, "config.yaml"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(raw, &config); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
```
|
||||
|
||||
**init() 的可接受使用场景:**
|
||||
- 无法用单个赋值完成的复杂表达式
|
||||
- 可插拔钩子(例如 `database/sql` 方言、编码注册表)
|
||||
- 确定性预计算
|
||||
|
||||
---
|
||||
|
||||
## Main 中的退出
|
||||
|
||||
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应
|
||||
返回 error 来表示失败。
|
||||
|
||||
**为什么这很重要:**
|
||||
- 不明显的控制流:任何函数都可以退出程序
|
||||
- 难以测试:退出程序的函数也会退出测试
|
||||
- 跳过的清理:`defer` 语句会被跳过
|
||||
|
||||
```go
|
||||
// 不好:在辅助函数中使用 log.Fatal
|
||||
func readFile(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatal(err) // 退出程序,跳过 defer
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:返回 error,让 main() 决定是否退出
|
||||
func main() {
|
||||
body, err := readFile(path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(body)
|
||||
}
|
||||
|
||||
func readFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
```
|
||||
|
||||
### run() 模式
|
||||
|
||||
优先在 `main()` 中**最多调用一次** `os.Exit` 或 `log.Fatal`。将
|
||||
业务逻辑提取到返回 error 的独立函数中。
|
||||
|
||||
```go
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
args := os.Args[1:]
|
||||
if len(args) != 1 {
|
||||
return errors.New("missing file")
|
||||
}
|
||||
|
||||
f, err := os.Open(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() // 将始终执行
|
||||
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理 b...
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**`run()` 模式的优势:**
|
||||
- 简短的 `main()` 函数,单一退出点
|
||||
- 所有业务逻辑都可测试
|
||||
- `defer` 语句始终执行
|
||||
|
||||
---
|
||||
|
||||
## 命令行接口
|
||||
|
||||
### Flag 命名
|
||||
|
||||
使用小写、连字符分隔的 flag 名称:
|
||||
|
||||
```go
|
||||
// 好
|
||||
flag.String("output-dir", ".", "directory for output files")
|
||||
flag.Bool("dry-run", false, "print actions without executing")
|
||||
|
||||
// 不好
|
||||
flag.String("outputDir", ".", "") // camelCase
|
||||
flag.String("output_dir", ".", "") // 下划线
|
||||
```
|
||||
|
||||
### 子命令
|
||||
|
||||
对于带有子命令的复杂 CLI,为每个子命令使用 `flag.NewFlagSet`:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
port := serveCmd.Int("port", 8080, "listen port")
|
||||
|
||||
migrateCmd := flag.NewFlagSet("migrate", flag.ExitOnError)
|
||||
dryRun := migrateCmd.Bool("dry-run", false, "preview changes")
|
||||
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
serveCmd.Parse(os.Args[2:])
|
||||
runServe(*port)
|
||||
case "migrate":
|
||||
migrateCmd.Parse(os.Args[2:])
|
||||
runMigrate(*dryRun)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对于更大的 CLI,考虑使用 `cobra` 或 `urfave/cli` 等库。仅从
|
||||
`main()` 退出。
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: go-performance
|
||||
description: Use when optimizing Go code, investigating slow performance, or writing performance-critical sections. Also use when a user mentions slow Go code, string concatenation in loops, or asks about benchmarking, even if the user doesn't explicitly mention performance patterns. Does not cover concurrent performance patterns (see go-concurrency).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Uber Style Guide, Google Style Guide, Go Wiki CodeReviewComments"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 性能模式
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/bench-compare.sh`** — 运行 Go 基准测试 N 次,并可选通过 benchstat 进行基线比较。支持保存结果以供未来比较。运行 `bash scripts/bench-compare.sh --help` 查看选项。
|
||||
|
||||
性能特定的指南仅适用于**热点路径**。不要过早优化——将这些模式集中在最重要的地方。
|
||||
|
||||
---
|
||||
|
||||
## 优先使用 strconv 而非 fmt
|
||||
|
||||
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快:
|
||||
|
||||
```go
|
||||
s := strconv.Itoa(rand.Int()) // 比 fmt.Sprint() 快约 2 倍
|
||||
```
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
> 在 strconv 和 fmt 之间选择类型转换方式时,或需要完整的转换对照表时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 避免重复的字符串到字节转换
|
||||
|
||||
将固定字符串在循环外转换为 `[]byte` 一次:
|
||||
|
||||
```go
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write(data) // 比每次迭代 []byte("...") 快约 7 倍
|
||||
}
|
||||
```
|
||||
|
||||
> 在优化热点循环中的重复字节转换时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 优先指定容器容量
|
||||
|
||||
尽可能指定容器容量,以便预先分配内存。这可以最大程度减少后续添加元素时因复制和调整大小而产生的分配。
|
||||
|
||||
### Map 容量提示
|
||||
|
||||
使用 `make()` 初始化 map 时提供容量提示:
|
||||
|
||||
```go
|
||||
m := make(map[string]os.DirEntry, len(files))
|
||||
```
|
||||
|
||||
**注意**:与 slice 不同,map 的容量提示不保证完整的预分配——它只是近似计算所需的哈希桶数量。
|
||||
|
||||
### Slice 容量
|
||||
|
||||
使用 `make()` 初始化 slice 时提供容量提示,特别是在追加时:
|
||||
|
||||
```go
|
||||
data := make([]int, 0, size)
|
||||
```
|
||||
|
||||
与 map 不同,slice 容量**不是提示**——编译器会精确分配那么多内存。后续的 `append()` 操作在达到容量之前不会产生任何分配。
|
||||
|
||||
| 方式 | 时间(1 亿次迭代) |
|
||||
|------|------------------------|
|
||||
| 无容量 | 2.48s |
|
||||
| 指定容量 | 0.21s |
|
||||
|
||||
指定容量的版本**快约 12 倍**,因为追加期间零重新分配。
|
||||
|
||||
---
|
||||
|
||||
## 传值
|
||||
|
||||
不要仅为了节省几个字节就将指针作为函数参数传递。如果函数在整个函数体中仅通过 `*x` 引用其参数 `x`,则该参数不应该是`指针。
|
||||
|
||||
```go
|
||||
func process(s string) { // 不是 *string —— string 是小的固定大小头部
|
||||
fmt.Println(s)
|
||||
}
|
||||
```
|
||||
|
||||
**常见的按值传递类型**:`string`、`io.Reader`、小结构体。
|
||||
|
||||
**例外**:
|
||||
- 复制代价高的大结构体
|
||||
- 未来可能增长的小结构体
|
||||
|
||||
---
|
||||
|
||||
## 字符串拼接
|
||||
|
||||
根据复杂度选择正确的策略:
|
||||
|
||||
| 方法 | 最佳用途 |
|
||||
|------|---------|
|
||||
| `+` | 少量字符串,简单拼接 |
|
||||
| `fmt.Sprintf` | 混合类型的格式化输出 |
|
||||
| `strings.Builder` | 循环/逐段构建 |
|
||||
| `strings.Join` | 连接 slice |
|
||||
| 反引号字面量 | 常量多行文本 |
|
||||
|
||||
> 在选择字符串拼接策略、在循环中使用 strings.Builder 或在 fmt.Sprintf 和手动拼接之间做决定时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 基准测试和性能分析
|
||||
|
||||
在优化前后始终要进行测量。使用 Go 内置的基准测试框架和性能分析工具。
|
||||
|
||||
```bash
|
||||
go test -bench=. -benchmem -count=10 ./...
|
||||
```
|
||||
|
||||
> 在编写基准测试、使用 benchstat 比较结果、使用 pprof 进行性能分析或解读基准测试输出时,阅读 [references/BENCHMARKS.md](references/BENCHMARKS.md)。
|
||||
|
||||
> **验证**:在应用优化后,运行 `bash scripts/bench-compare.sh` 测量实际影响。只保留有可衡量改进的优化。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 不好 | 好 | 改进 |
|
||||
|------|-----|------|-------------|
|
||||
| 整数转字符串 | `fmt.Sprint(n)` | `strconv.Itoa(n)` | 快约 2 倍 |
|
||||
| 重复 `[]byte` | 循环中 `[]byte("str")` | 在循环外转换一次 | 快约 7 倍 |
|
||||
| Map 初始化 | `make(map[K]V)` | `make(map[K]V, size)` | 更少分配 |
|
||||
| Slice 初始化 | `make([]T, 0)` | `make([]T, 0, cap)` | 快约 12 倍 |
|
||||
| 小型固定大小参数 | `*string`、`*io.Reader` | `string`、`io.Reader` | 无间接引用 |
|
||||
| 简单字符串连接 | `s1 + " " + s2` | (已经很好) | 对少量字符串使用 `+` |
|
||||
| 循环构建字符串 | 重复 `+=` | `strings.Builder` | O(n) vs O(n²) |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **数据结构**:在 slice、map 和数组之间选择或理解分配语义时,参见 [go-data-structures](../go-data-structures/SKILL.md)
|
||||
- **声明模式**:在使用 `make` 配合容量提示或初始化 map 和 slice 时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
- **并发**:在跨 goroutine 并行化工作或使用 sync.Pool 复用缓冲区时,参见 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **风格原则**:在判断优化是否值得牺牲可读性时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
@@ -0,0 +1,281 @@
|
||||
# 基准测试方法
|
||||
|
||||
## 编写基准测试
|
||||
|
||||
Go 基准测试使用 `testing.B` 类型,位于 `_test.go` 文件中。
|
||||
基准测试函数名必须以 `Benchmark` 开头。
|
||||
|
||||
```go
|
||||
func BenchmarkStrconv(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := strconv.Itoa(rand.Int())
|
||||
_ = s
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFmtSprint(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := fmt.Sprint(rand.Int())
|
||||
_ = s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键规则:
|
||||
- 使用 `b.N` 作为循环边界——框架会调整它以获得稳定的计时
|
||||
- 将结果赋值给变量(或 `_`),防止编译器优化掉调用
|
||||
- 在不需要测量的昂贵设置之后使用 `b.ResetTimer()`
|
||||
- 使用 `b.ReportAllocs()` 或 `-benchmem` 标志跟踪分配情况
|
||||
|
||||
### 子基准测试
|
||||
|
||||
```go
|
||||
func BenchmarkConvert(b *testing.B) {
|
||||
for _, size := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
|
||||
data := make([]byte, size)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = string(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行基准测试
|
||||
|
||||
```bash
|
||||
# 运行包中的所有基准测试
|
||||
go test -bench=. ./...
|
||||
|
||||
# 运行特定基准测试并显示内存统计
|
||||
go test -bench=BenchmarkStrconv -benchmem ./...
|
||||
|
||||
# 多次运行以获得统计显著性
|
||||
go test -bench=. -benchmem -count=10 ./...
|
||||
```
|
||||
|
||||
`-benchmem` 标志报告每次操作的分配次数。`-count` 标志将每个基准测试运行 N 次以获得统计显著性。
|
||||
|
||||
---
|
||||
|
||||
## 解读结果
|
||||
|
||||
```
|
||||
BenchmarkStrconv-8 18705042 64.2 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkFmtSprint-8 8249536 143.0 ns/op 16 B/op 2 allocs/op
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `-8` | GOMAXPROCS |
|
||||
| `18705042` | 迭代次数 |
|
||||
| `64.2 ns/op` | 每次操作时间 |
|
||||
| `16 B/op` | 每次操作分配的字节数 |
|
||||
| `1 allocs/op` | 每次操作的堆分配次数 |
|
||||
|
||||
---
|
||||
|
||||
## 使用 benchstat 进行比较
|
||||
|
||||
`benchstat` 对基准测试结果进行统计比较。安装它并将基准测试输出保存到文件:
|
||||
|
||||
```bash
|
||||
# 安装 benchstat
|
||||
go install golang.org/x/perf/cmd/benchstat@latest
|
||||
|
||||
# 运行基准测试并保存结果
|
||||
go test -bench=. -benchmem -count=10 ./... > old.txt
|
||||
|
||||
# 进行修改后再次运行
|
||||
go test -bench=. -benchmem -count=10 ./... > new.txt
|
||||
|
||||
# 比较结果
|
||||
benchstat old.txt new.txt
|
||||
```
|
||||
|
||||
### 解读 benchstat 输出
|
||||
|
||||
```
|
||||
name old time/op new time/op delta
|
||||
Strconv-8 64.2ns ± 2% 61.8ns ± 1% -3.74% (p=0.001 n=10+10)
|
||||
```
|
||||
|
||||
- **delta**:变化百分比(负数 = 更快)
|
||||
- **p-value**:统计显著性(p < 0.05 为显著)
|
||||
- **n**:使用的有效样本数量
|
||||
|
||||
提示:
|
||||
- 始终使用 `-count=10` 或更高以获得可靠结果
|
||||
- 小的 p 值确认变化是真实的,而非噪声
|
||||
- 如果 benchstat 显示 `~`(波浪号),则差异不具有统计显著性
|
||||
|
||||
---
|
||||
|
||||
## 来自性能模式的基准测试示例
|
||||
|
||||
### strconv vs fmt
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
### 重复字节转换
|
||||
|
||||
```go
|
||||
func BenchmarkRepeatedConversion(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Write([]byte("Hello world"))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSingleConversion(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Write(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 方式 | 速度 |
|
||||
|------|------|
|
||||
| 重复转换 | 22.2 ns/op |
|
||||
| 单次转换 | 3.25 ns/op |
|
||||
|
||||
### Slice 容量
|
||||
|
||||
```go
|
||||
func BenchmarkNoCapacity(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
data := make([]int, 0)
|
||||
for k := 0; k < 1000; k++ {
|
||||
data = append(data, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWithCapacity(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
data := make([]int, 0, 1000)
|
||||
for k := 0; k < 1000; k++ {
|
||||
data = append(data, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 方式 | 时间(1 亿次迭代) |
|
||||
|------|------------------------|
|
||||
| 无容量 | 2.48s |
|
||||
| 指定容量 | 0.21s |
|
||||
|
||||
---
|
||||
|
||||
## 使用 pprof 进行性能分析
|
||||
|
||||
使用 `pprof` 在优化前识别瓶颈。基准测试衡量改进效果;pprof 找到需要改进的地方。
|
||||
|
||||
### CPU 性能分析
|
||||
|
||||
```bash
|
||||
# 从基准测试生成 CPU 分析文件
|
||||
go test -bench=BenchmarkHotPath -cpuprofile=cpu.prof ./...
|
||||
|
||||
# 使用 pprof 分析
|
||||
go tool pprof cpu.prof
|
||||
```
|
||||
|
||||
常用 pprof 命令:
|
||||
|
||||
```
|
||||
(pprof) top10 # 按 CPU 时间排列的前 10 个函数
|
||||
(pprof) list funcName # 某个函数的带注释源码
|
||||
(pprof) web # 浏览器中的交互式图表
|
||||
```
|
||||
|
||||
### 内存性能分析
|
||||
|
||||
```bash
|
||||
# 生成内存分析文件
|
||||
go test -bench=BenchmarkHotPath -memprofile=mem.prof ./...
|
||||
|
||||
# 分析分配情况
|
||||
go tool pprof -alloc_space mem.prof
|
||||
```
|
||||
|
||||
### 运行中服务的 HTTP 性能分析
|
||||
|
||||
```go
|
||||
import _ "net/http/pprof"
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe("localhost:6060", nil))
|
||||
}()
|
||||
// ... 应用程序代码 ...
|
||||
}
|
||||
```
|
||||
|
||||
通过 `http://localhost:6060/debug/pprof/` 访问性能分析数据。
|
||||
|
||||
### 性能分析工作流
|
||||
|
||||
1. 对疑似热点路径进行**基准测试**
|
||||
2. 使用 pprof **分析**以确认时间花在了哪里
|
||||
3. 使用本技能中的模式进行**优化**
|
||||
4. **重新基准测试**以用 benchstat 验证改进
|
||||
5. **重新分析**以检查是否出现新的瓶颈
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 忽略 b.N
|
||||
|
||||
测试框架会调整 `b.N` 以获得稳定的计时。使用固定迭代次数会产生无意义的结果:
|
||||
|
||||
```go
|
||||
// 不好:忽略 b.N —— 基准测试框架无法校准
|
||||
func BenchmarkFixed(b *testing.B) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
|
||||
// 好:使用 b.N 作为循环边界
|
||||
func BenchmarkCorrect(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 未防止编译器优化消除
|
||||
|
||||
如果函数调用的结果未被使用,编译器可能会完全优化掉该调用。将结果赋值给包级变量:
|
||||
|
||||
```go
|
||||
// 不好:编译器可能会优化掉调用
|
||||
func BenchmarkElided(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
expensiveFunc()
|
||||
}
|
||||
}
|
||||
|
||||
// 好:赋值给包级变量以防止优化消除
|
||||
var benchResult int
|
||||
|
||||
func BenchmarkKept(b *testing.B) {
|
||||
var r int
|
||||
for i := 0; i < b.N; i++ {
|
||||
r = expensiveFunc()
|
||||
}
|
||||
benchResult = r
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,134 @@
|
||||
# 字符串优化模式
|
||||
|
||||
## strconv vs fmt
|
||||
|
||||
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快,因为 `fmt` 使用反射并处理任意类型。
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := fmt.Sprint(rand.Int())
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := strconv.Itoa(rand.Int())
|
||||
}
|
||||
```
|
||||
|
||||
**基准测试比较:**
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
常用转换:
|
||||
|
||||
| 任务 | `fmt` | `strconv` |
|
||||
|------|-------|-----------|
|
||||
| Int → string | `fmt.Sprint(n)` | `strconv.Itoa(n)` |
|
||||
| Int64 → string | `fmt.Sprint(n)` | `strconv.FormatInt(n, 10)` |
|
||||
| Float → string | `fmt.Sprint(f)` | `strconv.FormatFloat(f, 'f', -1, 64)` |
|
||||
| String → int | — | `strconv.Atoi(s)` |
|
||||
| Bool → string | `fmt.Sprint(b)` | `strconv.FormatBool(b)` |
|
||||
|
||||
---
|
||||
|
||||
## 重复的字符串到字节转换
|
||||
|
||||
不要重复从固定字符串创建字节切片。应该只转换一次并保存结果。
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write([]byte("Hello world"))
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write(data)
|
||||
}
|
||||
```
|
||||
|
||||
**基准测试比较:**
|
||||
|
||||
| 方式 | 速度 |
|
||||
|------|------|
|
||||
| 重复转换 | 22.2 ns/op |
|
||||
| 单次转换 | 3.25 ns/op |
|
||||
|
||||
好的版本**快约 7 倍**,因为它避免了每次迭代都分配新的字节切片。
|
||||
|
||||
---
|
||||
|
||||
## 字符串拼接
|
||||
|
||||
根据复杂度选择正确的字符串构建策略。
|
||||
|
||||
### 简单场景使用 `+`
|
||||
|
||||
```go
|
||||
key := "projectid: " + p
|
||||
```
|
||||
|
||||
`+` 运算符对于少量、固定数量的字符串是高效的。编译器通常可以优化相邻的字符串字面量。
|
||||
|
||||
### 格式化使用 `fmt.Sprintf`
|
||||
|
||||
```go
|
||||
// 好:清晰的格式化
|
||||
str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst)
|
||||
|
||||
// 不好:使用 + 手动转换
|
||||
str := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String()
|
||||
```
|
||||
|
||||
当写入 `io.Writer` 时,直接使用 `fmt.Fprintf` 而不是先用 `fmt.Sprintf` 构建临时字符串。
|
||||
|
||||
### 逐段构建使用 `strings.Builder`
|
||||
|
||||
`strings.Builder` 花费摊销线性时间,而重复使用 `+` 或
|
||||
`fmt.Sprintf` 在构建大字符串时花费二次时间:
|
||||
|
||||
```go
|
||||
b := new(strings.Builder)
|
||||
for i, d := range digitsOfPi {
|
||||
fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d)
|
||||
}
|
||||
str := b.String()
|
||||
```
|
||||
|
||||
### 常量多行字符串使用反引号
|
||||
|
||||
```go
|
||||
// 好:原始字符串字面量
|
||||
usage := `Usage:
|
||||
|
||||
custom_tool [args]`
|
||||
|
||||
// 不好:使用转义序列拼接
|
||||
usage := "" +
|
||||
"Usage:\n" +
|
||||
"\n" +
|
||||
"custom_tool [args]"
|
||||
```
|
||||
|
||||
### 策略总结
|
||||
|
||||
| 方法 | 最佳用途 | 性能 |
|
||||
|------|---------|------|
|
||||
| `+` | 少量字符串,简单拼接 | 小 n 时 O(n) |
|
||||
| `fmt.Sprintf` | 格式化输出 | 较慢,但更清晰 |
|
||||
| `strings.Builder` | 循环/逐段构建 | 摊销 O(n) |
|
||||
| `strings.Join` | 连接 slice | O(n) |
|
||||
| 反引号字面量 | 常量多行文本 | 零开销 |
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.1.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Run Go benchmarks with optional comparison
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [package]
|
||||
|
||||
DESCRIPTION
|
||||
Wrapper around 'go test -bench' that runs benchmarks multiple times and
|
||||
optionally compares results against a saved baseline using benchstat.
|
||||
|
||||
Results can be saved to a file for future comparison. If benchstat is
|
||||
installed and a baseline is provided, a statistical comparison is shown.
|
||||
|
||||
EXIT CODES
|
||||
0 Benchmarks ran successfully
|
||||
1 go test failed (compilation error, test failure, no benchmarks found)
|
||||
2 Usage error (missing arguments, bad flags, file exists without --force)
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
-n, --count N Number of benchmark iterations (default: 5)
|
||||
-b, --baseline FILE Compare results against this baseline file
|
||||
-s, --save FILE Save benchmark results to this file
|
||||
-f, --filter REGEX Benchmark filter regex (default: ".")
|
||||
--json Output metadata as JSON (human output goes to stderr)
|
||||
--benchmem Include memory allocation stats (default: on)
|
||||
--no-benchmem Disable memory allocation stats
|
||||
--force Allow --save to overwrite existing files
|
||||
--limit N Max benchmark result lines to include (default: 0 = all)
|
||||
|
||||
ARGUMENTS
|
||||
package Go package to benchmark (default: ./...)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME -n 10 ./pkg/parser
|
||||
bash $SCRIPT_NAME --save baseline.txt ./...
|
||||
bash $SCRIPT_NAME --baseline baseline.txt --save current.txt ./...
|
||||
bash $SCRIPT_NAME --filter BenchmarkSort -n 3
|
||||
bash $SCRIPT_NAME --json --limit 5 ./...
|
||||
bash $SCRIPT_NAME --save results.txt --force ./...
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
# Print human-readable output: stdout in text mode, stderr in JSON mode.
|
||||
log() {
|
||||
if $JSON_OUTPUT; then
|
||||
echo "$@" >&2
|
||||
else
|
||||
echo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
COUNT=5
|
||||
BASELINE=""
|
||||
SAVE=""
|
||||
FILTER="."
|
||||
PACKAGE=""
|
||||
JSON_OUTPUT=false
|
||||
BENCHMEM=true
|
||||
FORCE=false
|
||||
LIMIT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
-n|--count) COUNT="${2:?error: --count requires a number}"; shift 2 ;;
|
||||
-b|--baseline) BASELINE="${2:?error: --baseline requires a file path}"; shift 2 ;;
|
||||
-s|--save) SAVE="${2:?error: --save requires a file path}"; shift 2 ;;
|
||||
-f|--filter) FILTER="${2:?error: --filter requires a regex}"; shift 2 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--benchmem) BENCHMEM=true; shift ;;
|
||||
--no-benchmem) BENCHMEM=false; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) PACKAGE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PACKAGE="${PACKAGE:-./...}"
|
||||
|
||||
if ! command -v go &>/dev/null; then
|
||||
echo "error: 'go' command not found in PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "error: --count must be a positive integer, got: $COUNT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: --limit must be a non-negative integer, got: $LIMIT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE" && ! -f "$BASELINE" ]]; then
|
||||
echo "error: baseline file not found: $BASELINE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$SAVE" && -f "$SAVE" ]] && ! $FORCE; then
|
||||
echo "error: save target already exists: $SAVE (use --force to overwrite)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HAS_BENCHSTAT=false
|
||||
if command -v benchstat &>/dev/null; then
|
||||
HAS_BENCHSTAT=true
|
||||
fi
|
||||
|
||||
BENCH_ARGS=(-bench "$FILTER" -count "$COUNT" -run '^$')
|
||||
if $BENCHMEM; then
|
||||
BENCH_ARGS+=(-benchmem)
|
||||
fi
|
||||
|
||||
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bench-XXXXXX.txt")
|
||||
trap 'rm -f "$TMPFILE"' EXIT
|
||||
|
||||
log "Running benchmarks: go test ${BENCH_ARGS[*]} $PACKAGE"
|
||||
log "Iterations: $COUNT"
|
||||
log ""
|
||||
|
||||
GO_EXIT=0
|
||||
if $JSON_OUTPUT; then
|
||||
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" >&2 || GO_EXIT=$?
|
||||
else
|
||||
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" || GO_EXIT=$?
|
||||
fi
|
||||
|
||||
BENCH_COUNT=$(grep -cE '^Benchmark' "$TMPFILE" || true)
|
||||
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $BENCH_COUNT -gt $LIMIT ]]; then
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
if ! $JSON_OUTPUT && $TRUNCATED; then
|
||||
log ""
|
||||
log "Note: $BENCH_COUNT benchmark results found, showing first $LIMIT (--limit $LIMIT)"
|
||||
fi
|
||||
|
||||
if [[ -n "$SAVE" ]]; then
|
||||
cp "$TMPFILE" "$SAVE"
|
||||
log ""
|
||||
log "Results saved to: $SAVE"
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE" ]]; then
|
||||
log ""
|
||||
log "=== Comparison with baseline: $BASELINE ==="
|
||||
log ""
|
||||
if $HAS_BENCHSTAT; then
|
||||
if $JSON_OUTPUT; then
|
||||
benchstat "$BASELINE" "$TMPFILE" >&2 || true
|
||||
else
|
||||
benchstat "$BASELINE" "$TMPFILE" || true
|
||||
fi
|
||||
else
|
||||
log "note: install benchstat for statistical comparison:"
|
||||
log " go install golang.org/x/perf/cmd/benchstat@latest"
|
||||
log ""
|
||||
log "--- Baseline ---"
|
||||
if $JSON_OUTPUT; then
|
||||
grep -E '^Benchmark' "$BASELINE" >&2 || true
|
||||
else
|
||||
grep -E '^Benchmark' "$BASELINE" || true
|
||||
fi
|
||||
log ""
|
||||
log "--- Current ---"
|
||||
if $JSON_OUTPUT; then
|
||||
grep -E '^Benchmark' "$TMPFILE" >&2 || true
|
||||
else
|
||||
grep -E '^Benchmark' "$TMPFILE" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
FINAL_EXIT=0
|
||||
if [[ $GO_EXIT -ne 0 ]]; then
|
||||
FINAL_EXIT=1
|
||||
if ! $JSON_OUTPUT; then
|
||||
log ""
|
||||
log "error: go test exited with code $GO_EXIT"
|
||||
fi
|
||||
elif [[ $BENCH_COUNT -eq 0 ]]; then
|
||||
FINAL_EXIT=1
|
||||
if ! $JSON_OUTPUT; then
|
||||
log ""
|
||||
log "error: no benchmarks found matching filter: $FILTER"
|
||||
fi
|
||||
fi
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
BENCH_OUTPUT=$(<"$TMPFILE")
|
||||
if $TRUNCATED; then
|
||||
limited=""
|
||||
bench_seen=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^Benchmark ]]; then
|
||||
bench_seen=$((bench_seen + 1))
|
||||
if [[ $bench_seen -le $LIMIT ]]; then
|
||||
limited+="$line"$'\n'
|
||||
fi
|
||||
else
|
||||
limited+="$line"$'\n'
|
||||
fi
|
||||
done < "$TMPFILE"
|
||||
BENCH_OUTPUT="$limited"
|
||||
fi
|
||||
|
||||
escaped_package=$(json_escape "$PACKAGE")
|
||||
escaped_filter=$(json_escape "$FILTER")
|
||||
escaped_baseline=$(json_escape "$BASELINE")
|
||||
escaped_save=$(json_escape "$SAVE")
|
||||
escaped_output=$(json_escape "$BENCH_OUTPUT")
|
||||
|
||||
printf '{"count":%d,' "$COUNT"
|
||||
printf '"package":"%s",' "$escaped_package"
|
||||
printf '"filter":"%s",' "$escaped_filter"
|
||||
printf '"benchmarks_found":%d,' "$BENCH_COUNT"
|
||||
printf '"baseline":"%s",' "$escaped_baseline"
|
||||
printf '"save":"%s",' "$escaped_save"
|
||||
printf '"exit_code":%d,' "$GO_EXIT"
|
||||
printf '"output":"%s"' "$escaped_output"
|
||||
if $TRUNCATED; then
|
||||
printf ',"truncated":true'
|
||||
fi
|
||||
printf '}\n'
|
||||
fi
|
||||
|
||||
exit $FINAL_EXIT
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: "logstore"
|
||||
description: "Wavelet 项目专用:当新增或修改日志/分析用途表(访问日志、审计流水、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
|
||||
description: "OpenFlare / Wavelet:当新增或修改日志/分析用途表(节点访问日志、用户访问日志、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
|
||||
---
|
||||
|
||||
# 日志用途表开发
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。**
|
||||
|
||||
分层与切换协议见 [日志用途表](../../../docs/LOGSTORE.md)。
|
||||
设计背景见 [日志存储解耦](../../../docs/design/logstore.md)。
|
||||
|
||||
## 先判定
|
||||
|
||||
@@ -16,77 +16,60 @@ description: "Wavelet 项目专用:当新增或修改日志/分析用途表(
|
||||
- 追加写入、几乎不更新单行
|
||||
- 按时间查询/聚合,允许按保留天数删除
|
||||
- 关闭 ClickHouse 后仍要能写、能查
|
||||
- 不参与用户/配置/任务等事务一致性
|
||||
- 不参与网站/节点/证书等事务一致性
|
||||
|
||||
**不要**做成日志表:用户、配置、任务执行、上传元数据、需要事务或强一致的业务实体。这些走主库 `repository`,不要进 `logstore`。
|
||||
**不要**做成日志表:Zone、节点、配置版本、任务执行、上传元数据。这些走主库 `repository`。
|
||||
|
||||
当前框架已接入的日志表:`w_user_access_logs`(管理端 API 访问审计)。
|
||||
当前日志域:
|
||||
|
||||
| 域 | 接口 | 表 |
|
||||
| :--- | :--- | :--- |
|
||||
| 节点访问日志 | `AccessLogStore` | `of_node_access_logs` |
|
||||
| 可观测 | `ObservabilityStore` | `of_node_metric_snapshots` / `of_node_edge_health` / `of_node_obs_frps` / `of_node_obs_frpc` |
|
||||
| 用户访问审计 | `UserAccessLogStore` | `w_user_access_logs` |
|
||||
|
||||
## 分层
|
||||
|
||||
| 层级 | 路径 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里 |
|
||||
| CH 实现 | `logstore` 委托 `internal/repository/analytics` | 原生 `PrepareBatch` / `ChDB` 查询 |
|
||||
| 主库实现 | `logstore` GORM | PG(按月分区)与 SQLite(普通表) |
|
||||
| Model | `internal/model/analytics` | 实体、`TableName`、`InsertColumns`、`BatchInsertSQL`,无 IO |
|
||||
| 入队 | `internal/apps/<domain>` + `batchwriter` | `FlushFunc` 调 `logstore.Active().….BatchInsert` |
|
||||
| 切换 | `internal/apps/admin/logs` 的 `logs:db_switch` | 冻结写入 → 排空 → 复制 → 翻转 `log_database` |
|
||||
| 清理 | `logstore.CleanupExpired`,由 `system:cleanup` 调用 | 按库读取保留天数后 `DeleteBefore` |
|
||||
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里或 `repository` 门面 |
|
||||
| CH 实现 | `logstore/clickhouse_store.go` 委托 `analytics` | 原生批量 + 现有聚合 SQL |
|
||||
| 主库实现 | `logstore/postgres_store.go` | PG(按月分区)与 SQLite(普通表)共用 GORM |
|
||||
| Model | `internal/model/analytics` | 实体与批量 SQL,无 IO |
|
||||
| 入队 | `chwriter` / `risk_control` + `batchwriter` | flush 调 logstore `BatchInsert*`;CH 入队经 hooks |
|
||||
| 切换 | `of_log_db_switch` | 冻结 → `chwriter.Drain` → 逐表复制 → 翻转 |
|
||||
| 约束 | `logstore/imports_test.go` | apps 禁止 import `repository/analytics` |
|
||||
|
||||
`log_database` ∈ {`postgres`,`sqlite`,`clickhouse`},且只能是「随主库」或 ClickHouse:主库为 PG 时日志不能是 SQLite,反之亦然。`log_database` / `log_db_migration` 受保护,禁止管理端手动改。
|
||||
`log_database` 只能是「随主库」或 `clickhouse`。`log_database` / `log_db_migration` 受保护。
|
||||
|
||||
## 新增一张日志表
|
||||
|
||||
按顺序做,列名三库必须一致。
|
||||
|
||||
1. **Model**
|
||||
在 `internal/model/analytics/` 定义 struct;实现 `TableName()`;批量写再提供 `InsertColumns()` / `BatchInsertSQL()`。
|
||||
|
||||
2. **三套 DDL**(`database-migration`)
|
||||
- ClickHouse:`goose/clickhouse/`,`MergeTree`,`PARTITION BY toYYYYMM(时间列)`。
|
||||
- PostgreSQL:`goose/postgres/`,高频表用 `PARTITION BY RANGE (时间列)`,复合主键必须包含分区键。
|
||||
- SQLite:`goose/sqlite/`,普通表 + 时间/过滤列索引。
|
||||
不要在 PG/SQLite 上复制 CH 物化视图;聚合在查询时实时算。
|
||||
|
||||
3. **logstore 接口**
|
||||
在对应 Store(现有 `UserAccessLogStore`,或新域自建接口并挂到 `Store`)补齐至少:
|
||||
- 写入:`BatchInsert`(flush 目标;内调 `ensureWritable`)
|
||||
- 查询:业务需要的 List/Count/聚合
|
||||
- 迁移:`ListForMigration(afterID, limit)`、`MigrationRange`、`DeleteAll`、`EnsurePartitions`(PG 按月预建,CH/SQLite no-op)
|
||||
- 清理:`DeleteBefore(cutoff)`、`DropEmptyPartitions`、`DropExpiredPartitions`(仅 PG;CH/SQLite no-op)
|
||||
|
||||
4. **双实现**
|
||||
- CH:委托 `analyticsrepo`,零额外查询路径。
|
||||
- GORM:PG/SQLite 共用一套;方言 SQL 只放小函数(如按日 `to_char` / `strftime`)。零值 `id` 落库前用 `idgen.NextUint64ID()`。
|
||||
|
||||
5. **`buildStore`**
|
||||
在 `provider.go` 的 CH / GORM 分支同时挂上新域。
|
||||
|
||||
6. **写入**
|
||||
apps 用独立 `batchwriter` 实例;`FlushFunc` → `logstore.Active(ctx)` → `BatchInsert`。禁止 `analyticsrepo.BatchInsert`、禁止 `db.ChConn`。迁移任务调用域的 `Drain`(等队列空一个 flush 周期,不要 `Stop` writer)。
|
||||
|
||||
7. **切换任务**
|
||||
在 `copy*` 流程增加该表:`DeleteAll` 目标 → `MigrationRange` + `EnsurePartitions` → 按 id 分页复制。不要改切换协议(仍冻结写入、源数据不删、成功才翻转)。
|
||||
|
||||
8. **清理**
|
||||
`CleanupExpired`:PG 先 `DropExpiredPartitions`(整月过期分区),再 `DeleteBefore`(边界月),最后 `DropEmptyPartitions`。保留天数用已有 `log_retention_days_*`。apps 禁止 import `repository/analytics`(`imports_test.go`)。
|
||||
1. **Model**(`internal/model/analytics`):`TableName` + `InsertColumns` / `BatchInsertSQL`。
|
||||
2. **三套 DDL**:CH `MergeTree` + `toYYYYMM`;PG `PARTITION BY RANGE(时间列)`(主键含分区键);SQLite 普通表。不要在主库建 CH 物化视图,聚合实时算。
|
||||
3. **挂到已有域或新接口**:能进 `AccessLogStore` / `ObservabilityStore` / `UserAccessLogStore` 就不要再拆包。新域才新增接口并放进 `Store`。
|
||||
4. **方法最少集**:`BatchInsert`(含 `ensureWritable`)、业务查询、`ListForMigration`、`MigrationRange`、`DeleteAll`、`DeleteBefore`、`EnsurePartitions`(仅 PG 预建)。
|
||||
5. **双实现**:CH 委托 `analyticsrepo`;GORM 共用一套,方言 SQL 放 `dialect_*.go`。零值 id 用 `idgen.NextUint64ID()`。
|
||||
6. **`buildStore`**:CH / GORM 两分支都挂上。
|
||||
7. **写入**:独立 `batchwriter`;`FlushFunc` → `logstore.Active`。节点日志/可观测走 `SetAccessLogHooks` / `SetObservabilityHooks`,不要让 apps 碰 `ChConn`。
|
||||
8. **切换任务**:`clearTarget` + `copy*` 增加该表;源数据不删,失败不翻转。
|
||||
9. **清理**:访问类走 `log_retention_days_*`;性能指标走 `metric_retention_days`。不要擅自共用错误的 TTL。
|
||||
10. **import-lint**:apps 新增对 `analytics` 或 `infra/persistence`(`batchwriter`/`idgen` 除外)的 import 必须失败。
|
||||
|
||||
## 禁止
|
||||
|
||||
- apps 直接 `import` `internal/repository/analytics` 或 `db.ChConn` / `db.ChDB` 做日志读写
|
||||
- 只建 CH 表、不建 PG/SQLite 回落
|
||||
- 在 Handler 里逐条 `PrepareBatch` + `Send`
|
||||
- 把业务表「顺便」放进 logstore 以便关 CH
|
||||
- 管理端 API 改 `log_database` / `log_db_migration`
|
||||
- apps 直连 `analyticsrepo` / `db.ChConn` / `db.ChDB` 做日志读写
|
||||
- 只建 CH、不建主库回落
|
||||
- Handler 内逐条 `PrepareBatch`
|
||||
- 业务表塞进 logstore
|
||||
- 管理端改 `log_database` / `log_db_migration`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./internal/repository/logstore ./internal/repository/analytics
|
||||
go test ./internal/apps/admin/logs ./internal/apps/risk_control ./internal/platform/bootstrap
|
||||
make swagger # 若改了状态/查询 API
|
||||
go test ./internal/apps/openflare/... ./internal/apps/admin/logs ./internal/apps/admin/status
|
||||
make swagger
|
||||
make code-check
|
||||
```
|
||||
|
||||
对照:`w_user_access_logs` 的 model、三库 goose、`logstore` GORM/CH、`risk_control.InitLogWriter`、`logs.LogDBSwitchHandler`、`system:cleanup`。
|
||||
对照:`of_node_access_logs` 或 `w_user_access_logs` 的 model、三库 goose、`logstore` 双实现、`chwriter`/`risk_control` flush、`LogDBSwitchHandler`。
|
||||
|
||||
+122
-192
@@ -1,216 +1,146 @@
|
||||
---
|
||||
name: "new-api"
|
||||
description: "Wavelet 项目专用:当新增或修改业务 API、Handler、服务层逻辑、插件路由注册时必须使用。本技能指导基于 Cordis 插件的 API 架构、ctx.Router() 声明式路由注册、Handler/Service 分层、Swagger 与质量门禁。"
|
||||
description: "Wavelet 项目专用:当新增或修改自定义业务 API、新增业务路由、新增 service 层核心逻辑时必须使用。本技能指导包职责划分、推荐文件结构、路由解耦、Swagger 文档生成与质量门禁验证。"
|
||||
---
|
||||
|
||||
# 新增业务 API 开发与路由注册规范 (Cordis 插件化架构)
|
||||
# 新增业务 API 开发与路由注册规范
|
||||
|
||||
本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行 HTTP API 接口开发与路由注册的唯一指导规范。
|
||||
本技能是 Wavelet 项目接口开发与路由注册的唯一指导规范。在开发任何新接口前,请严格按照本指南进行架构决策与路由注册。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心架构哲学:插件自包含 (Self-Contained Plugins)
|
||||
## 核心路由准则与防线 (Routing Governance & Guardrails)
|
||||
|
||||
在 Cordis 架构中,**业务 API 不再集中在旧的 `internal/router/` 或 `internal/apps/` 目录**。
|
||||
所有业务能力均封装为**高内聚、扁平自包含的插件 (Plugin)**。每个插件自主管理自身的路由声明、中间件挂载、服务逻辑、数据模型与迁移脚本。
|
||||
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
|
||||
|
||||
### 插件目录推荐结构 (`backend/plugins/domain/<name>/` 或下游 `custom_plugins/<name>/`)
|
||||
1. **禁止修改框架级路由文件**:
|
||||
- 以下文件属于系统框架/平台级接口,**禁止为了添加自定义业务接口而进行任何修改**:
|
||||
- `internal/router/router.go`(核心入口委派)
|
||||
- `internal/router/root/default.go`(公开文件服务、robots.txt、Swagger 及 /api/health 路由)
|
||||
- `internal/router/root/frontend.go`(前端静态服务)
|
||||
- `internal/router/v1/v1.go`(V1 分发层协调器)
|
||||
- `internal/router/v1/admin.go`(框架管理员端管理接口)
|
||||
- `internal/router/v1/user.go`(框架普通用户端基础接口、OAuth及公开接口)
|
||||
2. **仅允许在 `custom.go` 中注册业务接口**:
|
||||
- 所有的自定义/业务相关接口注册,有且仅有以下两个合法的承载点:
|
||||
- [internal/router/root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go)(用于挂载到根路径的特殊业务接口)
|
||||
- [internal/router/v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go)(用于挂载在 API V1 下的标准自定义业务接口)
|
||||
|
||||
---
|
||||
|
||||
## 路由归属判定表 (Where should I register my new API?)
|
||||
|
||||
根据接口的**访问路径特征**和**访问身份/限制条件**,决定将新开发的 API 挂载至何处:
|
||||
|
||||
| 目标 API 路径特征 | 访问身份/条件限制 | 对应的路由注册入口 | 是否允许修改 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`/my-custom-path`** (挂载在根路径下的特殊业务接口) | 自定义控制 | `root/custom.go` 中的 `RegisterCustomRootRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/custom/...`** (API v1 下的定制业务接口) | 自定义控制 | `v1/custom.go` 中的 `RegisterCustomRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/admin/...`** (系统管理员管理端接口) | 需要管理员登录 (`admin.LoginAdminRequired()`) | `v1/admin.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/user/...`** (框架普通用户基础接口) | 需要普通用户登录 (`oauth.LoginRequired()`) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/public/...`** (Captcha、Config 等系统公开接口) | 所有人 (无条件 / 公开) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`GET /f/:id`**, **`GET /robots.txt`**, **`GET /api/health`** (系统级默认及公开接口) | 所有人 (无条件 / 公开) | `root/default.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
|
||||
---
|
||||
|
||||
## 两个自定义路由包的用法与区别 (Root Custom vs V1 Custom)
|
||||
|
||||
### 1. 根路径自定义包:`root/custom.go`
|
||||
|
||||
* **适用场景**:适用于需要**直接挂载在主域名根路径下**的特殊自定义业务接口(如第三方 Webhook 回调、特定的短链接重定向、外部数据接口等,不需要 `/api/v1` 前缀)。
|
||||
* **用法示例**:
|
||||
在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 中实现:
|
||||
```go
|
||||
package root
|
||||
|
||||
import (
|
||||
"OpenFlare/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRootRoutes registers custom business routes that belong to the root path.
|
||||
func RegisterCustomRootRoutes(r *gin.Engine) {
|
||||
// 挂载到根路径下,如 GET /my-custom-webhook
|
||||
r.GET("/my-custom-webhook", custom.HandleRootWebhook)
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `root.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
### 2. V1 API 自定义包:`v1/custom.go`
|
||||
|
||||
* **适用场景**:适用于普通的**自定义业务 API**,需要规范挂载在标准 API V1 路径下(即自动带有 `/api/v1/custom/...` 前缀,可选择性配置用户/管理员登录中间件)。
|
||||
* **用法示例**:
|
||||
在 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中实现:
|
||||
```go
|
||||
package v1
|
||||
|
||||
import (
|
||||
"OpenFlare/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRoutes registers standard custom API routes under /api/v1.
|
||||
func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
|
||||
customRouter := apiV1Router.Group("/custom")
|
||||
{
|
||||
// 挂载到 /api/v1/custom 下,例如:POST /api/v1/custom/action
|
||||
customRouter.POST("/action", custom.DoActionHandler)
|
||||
}
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `v1/v1.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
---
|
||||
|
||||
## 建议创建/修改的文件结构 (Recommended Directory Structure)
|
||||
|
||||
当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,建议采用以下标准文件结构:
|
||||
|
||||
#### 模式 1:极简单文件自包含(适用于极简微型插件 / 单一实体 / <500行)
|
||||
```text
|
||||
backend/plugins/domain/demo/
|
||||
├── plugin.go # 插件入口:实现 core.Plugin,通过 ctx.Router() 挂载路由
|
||||
├── handlers.go # HTTP 控制器单文件:参数校验、上下文提取、调用 Service、信封响应
|
||||
├── service.go # 业务服务层单文件:纯 Go 逻辑,仅依赖 context.Context
|
||||
├── repository.go # 数据库访问层单文件:GORM 查询、SQL 防注入与转义
|
||||
├── models.go # GORM 数据实体定义(自带表前缀)与 DTO
|
||||
├── errs.go # 模块内错误常量定义(camelCase 字符串)
|
||||
└── migrations/ # 专属嵌入式 Goose SQL 迁移脚本
|
||||
└── 20260827000001_create_demo_table.sql
|
||||
```
|
||||
> ⚠️ **严禁**:当需要拆分多个 Handler/Service 文件时,**严禁在根目录平铺 `handlers_*.go`、`service_*.go`、`repository_*.go` 等前缀文件**,必须立即采用模式 2(独立子包分层)。
|
||||
|
||||
#### 模式 2:标准独立子包分层架构(适用于标准/中大型业务插件 / 官方推荐标准)
|
||||
```text
|
||||
backend/plugins/domain/order/
|
||||
├── plugin.go # 插件根入口:实现 core.Plugin,装配各子包并向 Cordis 注册
|
||||
│
|
||||
├── handler/ # package handler:HTTP 控制器与路由声明(或 controller/)
|
||||
│ ├── router.go # 路由组声明与中间件挂载
|
||||
│ └── order.go # 订单 Handler(直接以业务命名,禁止 handlers_order.go)
|
||||
│
|
||||
├── service/ # package service:业务逻辑层(用例编排、事件发布)
|
||||
│ ├── service.go # Service 接口与组装
|
||||
│ └── order.go # 订单业务用例实现(直接以业务命名,禁止 service_order.go)
|
||||
│
|
||||
├── repository/ # package repository:数据持久化访问层 (DAL)
|
||||
│ ├── repository.go # 仓储抽象与通用工厂
|
||||
│ └── order.go # 订单仓储实现(直接以业务命名,禁止 repository_order.go)
|
||||
│
|
||||
├── model/ # package model (或 models/):纯数据实体与 DTO(无外部依赖)
|
||||
│ ├── entity.go # 数据库映射实体 (TableName() 带插件专属前缀)
|
||||
│ ├── dto.go # 请求与响应 DTO
|
||||
│ └── events.go # 领域事件定义
|
||||
│
|
||||
├── errs/ # package errs:错误常量与错误码 (或根目录 errs.go)
|
||||
│ └── errs.go
|
||||
│
|
||||
└── migrations/ # 专属嵌入式 Goose SQL 迁移脚本
|
||||
└── 20260827000001_create_orders_table.sql
|
||||
internal/
|
||||
├── router/
|
||||
│ ├── root/
|
||||
│ │ └── custom.go # [修改] 若为根路径 API,在此处注册,将路由委派给 apps/custom
|
||||
│ └── v1/
|
||||
│ └── custom.go # [修改] 若为 v1 API,在此处注册,将路由委派给 apps/custom
|
||||
└── apps/
|
||||
└── custom/
|
||||
├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应
|
||||
├── logics.go # [新建] 业务逻辑层:承载模块内闭环的纯 Go 业务逻辑,不依赖 gin.Context
|
||||
└── errs.go # [新建] 存放模块特有的业务错误常量定义(可选)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 插件契约与路由注册流程
|
||||
## 核心开发步骤 (Step-by-Step Flow)
|
||||
|
||||
### 步骤 1:定义插件结构并实现 `core.Plugin`
|
||||
### 步骤 1:数据库定义与迁移
|
||||
如果自定义功能涉及新表或字段,请参考 [database-migration](../database-migration/SKILL.md) 技能,在 `internal/infra/persistence/migrator/goose/` 目录下编写迁移文件,在 `internal/model/` 中定义 GORM 实体(无 CRUD / 无 DB 访问),并在 `internal/repository/` 中实现数据访问(**repository 为唯一持久化入口**)。
|
||||
|
||||
插件必须实现 `core.Plugin` 接口:
|
||||
### 步骤 2:在模块内实现业务逻辑 (`logics.go` / `service.go`)
|
||||
业务逻辑逻辑应当实现于 `internal/apps/custom/` 目录下:
|
||||
- **优先使用纯函数(`logics.go`)**:定义接收 `context.Context` 且不依赖 `*gin.Context` 的函数,易于单元测试与 Worker 复用。参考 `internal/apps/user/logics.go`。
|
||||
- **有状态服务(`service.go`)**:若需注入依赖(如 DB 连接、外部客户端等),可定义 Service 结构体和构造函数。
|
||||
- **跨模块副作用(推送、任务监听等)**:核心业务代码通过 `internal/listener` 发射域事件,禁止直接 `import` push 模块;装配在 `internal/platform/bootstrap` 完成(参见 `push-notification` skill)。
|
||||
|
||||
```go
|
||||
package order
|
||||
### 步骤 3:编写 HTTP Handler (`routers.go`)
|
||||
在 `internal/apps/custom/routers.go` 中编写 Handler:
|
||||
- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。
|
||||
- 负责提取 Session / 用户身份。
|
||||
- 调用业务逻辑层,并使用 `OpenFlare/internal/shared/response` 统一返回响应:
|
||||
- 成功时返回:`response.OK(data)` 或 `response.OKNil()`
|
||||
- 失败时返回:`response.Err(msg)`
|
||||
- 编写规范的 Swagger 注释。
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
svc *OrderService
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string {
|
||||
return "domain.order"
|
||||
}
|
||||
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. 初始化业务 Service
|
||||
p.svc = NewOrderService(ctx)
|
||||
|
||||
// 2. 如果需要对外暴露服务,注入 IoC 容器供其他插件消费
|
||||
// core.Provide[contracts.OrderService](ctx, p.svc)
|
||||
|
||||
// 3. 注册 HTTP 路由与中间件
|
||||
p.registerRoutes(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2:通过 `ctx.Router()` 挂载路由组与中间件
|
||||
|
||||
通过微内核扩展点 `ctx.Router()` 声明式挂载语义化路由与鉴权中间件:
|
||||
|
||||
```go
|
||||
func (p *Plugin) registerRoutes(ctx *core.Context) {
|
||||
// 获取认证服务提供的标准中间件(若需要)
|
||||
authSvc, _ := core.Inject[contracts.AuthService](ctx)
|
||||
|
||||
// 创建带语义化版本前缀的路由组
|
||||
group := ctx.Router().Group("/api/v1/orders")
|
||||
if authSvc != nil {
|
||||
group.Use(authSvc.RequireAuthMiddleware())
|
||||
}
|
||||
|
||||
// 绑定 Handler
|
||||
group.GET("", p.handleListOrders)
|
||||
group.POST("", p.handleCreateOrder)
|
||||
group.GET("/:id", p.handleGetOrderDetail)
|
||||
group.PUT("/:id/cancel", p.handleCancelOrder)
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 3:公开接口与白名单注册 (`RegisterWhitelist`)
|
||||
|
||||
如果插件包含**无需登录**的公开端点(如登录、注册、人机校验、Webhooks、公开状态查询),必须在 `Apply` 中主动注册到白名单:
|
||||
|
||||
```go
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 注册公开接口白名单(支持精确路径与通配符如 /api/v1/oauth/*)
|
||||
ctx.Router().RegisterWhitelist(
|
||||
"/api/v1/public/ping",
|
||||
"/api/v1/public/webhook/*",
|
||||
)
|
||||
|
||||
// 或在子路由组中相对注册:
|
||||
publicGroup := ctx.Router().Group("/api/v1/public")
|
||||
publicGroup.RegisterWhitelist("/status", "/docs/*")
|
||||
...
|
||||
}
|
||||
```
|
||||
> 💡 **防线机制**:注册到白名单的路由在经过 `auth.RequireAuthMiddleware()` 时将自动放行,彻底消除全局/组级鉴权中间件引起的 401 Unauthorized 误拦截。
|
||||
### 步骤 4:在自定义包中注册路由并委派
|
||||
根据 **路由归属判定表**,在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 或 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中编写注册代码,将路由路径绑定到步骤 3 中编写的 Handler。
|
||||
|
||||
---
|
||||
|
||||
## 3. Handler 与 Service 职责划分
|
||||
## 质量验证门禁 (Quality Gates)
|
||||
|
||||
### Handler 规范 (`handlers.go`)
|
||||
Handler 负责协议接入层:
|
||||
1. 参数绑定:使用 `c.ShouldBindJSON` 或 `c.ShouldBindQuery`。
|
||||
2. 提取当前登录用户信息(如 `oauth.GetCurrentUser(c)`)。
|
||||
3. 调用底层纯函数或 Service 逻辑。
|
||||
4. 错误处理:统一使用 `response.Abort*` 系列函数中断请求,禁止直接 `c.JSON(status, response.Err(...))`。
|
||||
5. 成功响应:使用 `c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。
|
||||
6. 编写完整的 Swagger / OpenAPI 注释。
|
||||
|
||||
```go
|
||||
// @Summary 创建订单
|
||||
// @Description 创建一笔新的业务订单
|
||||
// @Tags Order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body CreateOrderRequest true "创建订单参数"
|
||||
// @Success 200 {object} response.Envelope{data=OrderDTO} "创建成功"
|
||||
// @Failure 400 {object} response.Envelope "参数绑定失败"
|
||||
// @Failure 401 {object} response.Envelope "未授权"
|
||||
// @Router /api/v1/orders [post]
|
||||
func (p *Plugin) handleCreateOrder(c *gin.Context) {
|
||||
var req CreateOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, errs.ErrBindParamsFailed)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := oauth.GetCurrentUser(c)
|
||||
if !ok {
|
||||
response.AbortUnauthorized(c, errs.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := p.svc.CreateOrder(c.Request.Context(), user.ID, req)
|
||||
if err != nil {
|
||||
// 底层已记录日志,此处根据业务错误码响应
|
||||
response.AbortInternal(c, errs.ErrCreateOrderFailed)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(order))
|
||||
}
|
||||
```
|
||||
|
||||
### Service / Logics 规范 (`service.go`)
|
||||
1. 纯 Go 逻辑,第一参数为 `ctx context.Context`,返回 `(result, error)`。
|
||||
2. **严禁依赖 `*gin.Context`** 或调用 `c.JSON`/`Abort*`。
|
||||
3. 数据库操作通过 `ctx.DB()` 或受 Trace 保护的 DB 实例完成。
|
||||
4. 缓存操作通过 `ctx.Cache()` 完成。
|
||||
|
||||
---
|
||||
|
||||
## 4. 跨插件依赖与防线 (Guardrails)
|
||||
|
||||
1. **严禁跨插件 import 内部实现**:插件之间不得直接 import 对方包中的具体结构体或私有逻辑。
|
||||
2. **面向契约编程**:跨插件调用一律在 `core/contracts/` 中定义 Interface,通过 `core.Provide` 注册、`core.Inject` 或 `ctx.Using` 延迟解析。
|
||||
3. **事件驱动通知**:涉及跨域状态联动(如用户注册成功、订单支付完成),统一使用 `ctx.Events().Emit(...)` 广播领域事件,由订阅方自愿监听,消除循环依赖。
|
||||
|
||||
---
|
||||
|
||||
## 5. 质量验证门禁
|
||||
|
||||
在完成 API 开发后,必须依次运行以下命令:
|
||||
```bash
|
||||
make license # 确保新文件具有开源许可头
|
||||
make swagger # 重新生成 Swagger 文档
|
||||
make format # 代码自动格式化
|
||||
make code-check # 静态代码质量检查 (golangci-lint)
|
||||
go test ./plugins/... # 运行插件单元测试
|
||||
```
|
||||
每次新增或修改接口后,必须运行并验证以下各项:
|
||||
1. **自动授权许可**:`make license`(新增 Go 文件时自动添加许可头)
|
||||
2. **重新生成 Swagger 文档**:`make swagger`(若有 Swagger 注释修改)
|
||||
3. **静态代码及风格检查**:`make code-check`(确保通过 golangci-lint 和前端 TS 检查)
|
||||
4. **自动化单元测试**:`go test ./...`(确保所有测试 100% 通过)
|
||||
|
||||
@@ -6,50 +6,53 @@ package references
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||
"OpenFlare/internal/service"
|
||||
"OpenFlare/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// createChannelRequest 客户端请求体 DTO
|
||||
type createChannelRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
// customRequest 客户端请求体 DTO
|
||||
type customRequest struct {
|
||||
Payload string `json:"payload" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
// createChannelResponse API 响应体 DTO
|
||||
type createChannelResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// customResponse API 响应体 DTO
|
||||
type customResponse struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// CreateChannel 示例:插件内 HTTP Handler(位于 plugins/domain/channel/handlers.go)
|
||||
// @Summary 创建频道
|
||||
// @Description 示例:语义路径下的业务接口
|
||||
// @Tags channel
|
||||
// HandleCustomBusiness 示例 API Handler
|
||||
// @Summary 示例定制业务接口
|
||||
// @Description 接收数据载荷,调用 Service 执行核心逻辑,并返回统一格式的 JSON 结果。
|
||||
// @Tags custom
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body createChannelRequest true "业务请求参数"
|
||||
// @Success 200 {object} response.Any{data=createChannelResponse} "操作成功"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Router /api/v1/channels [post]
|
||||
func CreateChannel(c *gin.Context) {
|
||||
var req createChannelRequest
|
||||
// @Param request body customRequest true "业务请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=customResponse} "操作成功"
|
||||
// @Router /api/v1/custom/business [post]
|
||||
func HandleCustomBusiness(c *gin.Context) {
|
||||
// 1. 参数绑定与校验
|
||||
var req customRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, "参数校验失败")
|
||||
c.JSON(http.StatusBadRequest, util.Err("参数校验失败:载荷不能为空且在 1-100 字符内"))
|
||||
return
|
||||
}
|
||||
|
||||
// 从请求上下文中提取认证用户
|
||||
// 2. 模拟获取当前上下文与已登录用户(例如从 Session 中提取)
|
||||
// 通常结合 oauth.LoginRequired() 等中间件使用
|
||||
userID := int64(9527)
|
||||
|
||||
result, err := CreateChannelLogic(c.Request.Context(), userID, req.Name)
|
||||
// 3. 实例化业务 Service 并调用核心逻辑
|
||||
// 注意传入 c.Request.Context() 以正确传递 OpenTelemetry Tracing 等上下文信息
|
||||
svc := service.NewCustomService()
|
||||
resText, err := svc.ProcessBusinessData(c.Request.Context(), userID, req.Payload)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(createChannelResponse{
|
||||
ID: result.ID,
|
||||
Name: result.Name,
|
||||
// 4. 返回符合外层形状规范 { "error_msg": "", "data": ... } 的统一成功响应
|
||||
c.JSON(http.StatusOK, util.OK(customResponse{
|
||||
Result: resText,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -8,30 +8,25 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"OpenFlare/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// channelCreated 示例业务返回值(真实代码可用 model 或专用 DTO)
|
||||
type channelCreated struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
// CreateChannelLogic 示例:插件内业务纯函数(位于 plugins/domain/channel/logics.go)
|
||||
// 接收 context.Context,不依赖 gin.Context,便于单测与 Worker 复用。
|
||||
func CreateChannelLogic(ctx context.Context, userID int64, name string) (*channelCreated, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("name cannot be empty")
|
||||
// ProcessLocalBusiness 示例的模块内部闭环业务逻辑
|
||||
// 1. 存放在 apps/custom/logics.go 下,遵循纯 Go 规范,不强依赖 gin.Context,以便逻辑清晰和便于单元测试。
|
||||
// 2. 用于当前应用模块内的简单业务或通用过程。
|
||||
func ProcessLocalBusiness(ctx context.Context, userID int64, param string) (string, error) {
|
||||
if param == "" {
|
||||
return "", errors.New("param cannot be empty")
|
||||
}
|
||||
|
||||
logger.Info(ctx, "creating channel",
|
||||
logger.Info(ctx, "processing local business inside apps/custom/logics",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("name", name),
|
||||
zap.String("param", param),
|
||||
)
|
||||
|
||||
return &channelCreated{
|
||||
ID: 1,
|
||||
Name: fmt.Sprintf("%s (by %d)", name, userID),
|
||||
}, nil
|
||||
// 执行轻量级、无需跨模块/多入口复用的本地计算或模型操作
|
||||
result := fmt.Sprintf("Processed local logic for user %d: %s", userID, param)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -8,33 +8,38 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"OpenFlare/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ChannelService 示例有状态 Service(位于 plugins/domain/channel/service.go)
|
||||
// 需要注入 DB/缓存时使用;简单逻辑优先 logics.go 纯函数。
|
||||
type ChannelService struct {
|
||||
// CustomService 示例业务 Service 结构体(通常放在 internal/apps/custom/service.go 中)
|
||||
type CustomService struct {
|
||||
// 这里可以注入数据库连接、配置对象或者其他基础服务的客户端
|
||||
// 例如:db *gorm.DB
|
||||
}
|
||||
|
||||
// NewChannelService 构造函数
|
||||
func NewChannelService() *ChannelService {
|
||||
return &ChannelService{}
|
||||
// NewCustomService 创建 CustomService 实例的构造函数
|
||||
func NewCustomService() *CustomService {
|
||||
return &CustomService{}
|
||||
}
|
||||
|
||||
// Create 核心业务:首位参数必须是 context.Context;禁止依赖 Gin。
|
||||
func (s *ChannelService) Create(ctx context.Context, userID int64, name string) (int64, error) {
|
||||
if name == "" {
|
||||
return 0, errors.New("name cannot be empty")
|
||||
// ProcessBusinessData 演示核心业务处理逻辑的 Service 方法
|
||||
// 1. 首位参数必须是 context.Context,以传播链路追踪 (OTel) 和超时控制。
|
||||
// 2. 方法签名应该只包含纯 Go 的参数与返回值,禁止导入 Gin 或与 HTTP 相关的协议依赖。
|
||||
// 3. 将可能发生的核心异常通过 error 返回给上层,而不是在这一层转换成 HTTP 状态码。
|
||||
func (s *CustomService) ProcessBusinessData(ctx context.Context, userID int64, payload string) (string, error) {
|
||||
if payload == "" {
|
||||
return "", errors.New("payload cannot be empty")
|
||||
}
|
||||
|
||||
logger.Info(ctx, "channel service create",
|
||||
// 模拟执行业务逻辑...
|
||||
logger.Info(ctx, "processing custom business data in service",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("name", name),
|
||||
zap.String("payload", payload),
|
||||
)
|
||||
|
||||
// DB 事务、远程调用等
|
||||
_ = fmt.Sprintf("user=%d name=%s", userID, name)
|
||||
return 1, nil
|
||||
// 这里可以包含数据库读写、事务控制、或者远程 API 调用等复杂逻辑。
|
||||
result := fmt.Sprintf("Success processed data for user %d: %s", userID, payload)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,157 +1,122 @@
|
||||
---
|
||||
name: "new-async-task"
|
||||
description: "Wavelet 项目专用:新增或修改基于 Cordis 插件的 Asynq 异步任务、后台 Worker 消费处理器、Cron 定时调度任务与任务执行追踪时必须使用。"
|
||||
description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台任务、定时任务、任务元数据、TaskHandler、TaskParam、PayloadValidator、AppendLog、任务重试、任务执行记录或 Admin 任务 API 时必须使用。"
|
||||
---
|
||||
|
||||
# 异步任务与定时调度开发规范 (Cordis 插件化架构)
|
||||
# 异步任务开发
|
||||
|
||||
本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行 Asynq 异步后台任务与 Cron 定时调度开发的唯一指导规范。
|
||||
开始前阅读根目录 `AGENTS.md`。只修改任务相关链路,遵守项目路由、日志、数据库迁移和质量门禁要求。
|
||||
|
||||
---
|
||||
## 开始前
|
||||
|
||||
## 1. 核心架构:插件内自包含任务声明
|
||||
按任务范围检查当前实现:
|
||||
|
||||
在 Cordis 架构中,后台 Worker 消费与定时调度**不再集中在中心化的注册表**,而是由各个业务插件在自身的 `Apply` 方法中通过微内核扩展点直接声明。
|
||||
- `internal/infra/task/handler.go`:`TaskHandler`、`TaskResult`、`PayloadValidator`
|
||||
- `internal/infra/task/meta.go`:`TaskMeta`、`TaskParam`
|
||||
- `internal/infra/task/executor.go`:下发、执行、日志、重试、`OnTaskCompleted` 订阅
|
||||
- `internal/infra/task/handlers/register.go`:Handler 和元数据注册(由 bootstrap 调用)
|
||||
- `internal/platform/bootstrap/bootstrap.go`:任务注册与进程级装配入口
|
||||
- `internal/infra/task/worker/worker.go`:Worker 路由和队列
|
||||
- `internal/infra/task/scheduler/scheduler.go`:定时调度
|
||||
- `internal/apps/admin/task/routers.go`:Admin 任务 API
|
||||
- `internal/model/task_execution.go`:执行记录实体与 DTO
|
||||
- `internal/repository/task_execution.go`:执行记录和日志持久化
|
||||
|
||||
### 扩展点矩阵
|
||||
需要模板时阅读 [references/CODE-EXAMPLES.md](references/CODE-EXAMPLES.md)。
|
||||
|
||||
| 扩展点方法 | 说明 | 适用场景 |
|
||||
| :--- | :--- | :--- |
|
||||
| `ctx.Task().Register(pattern, handler, opts...)` | 注册 Asynq 任务类型与消费处理器 | 异步耗时计算、队列任务、通知外发 |
|
||||
| `ctx.Schedule().RegisterCron(spec, taskType, payload)` | 注册 Cron 表达式定时调度任务 | 周期统计、定时清理、健康检查 |
|
||||
## 实现要求
|
||||
|
||||
---
|
||||
### 任务定义
|
||||
|
||||
## 2. 异步任务开发全流程
|
||||
- 在 `internal/apps/<module>/tasks.go` 定义任务类型、Admin 任务类型和 `TaskMeta`。
|
||||
- Asynq 任务类型使用 `<module>:<action>` 格式。
|
||||
- 完整设置 `Type`、`AsynqTask`、`Name`、`Description`、`MaxRetry`、`Queue`、`Retryable`。
|
||||
- 有参数任务必须定义 payload struct。
|
||||
- `TaskParam.Name` 必须与 payload JSON tag 一致。
|
||||
- `TaskParam` 只描述前端表单,不代替服务端校验。
|
||||
|
||||
### 步骤 1:定义任务 Payload 结构与类型常量
|
||||
### Handler
|
||||
|
||||
在插件内(如 `backend/plugins/domain/order/tasks.go`):
|
||||
- Handler 必须实现 `task.TaskHandler`。
|
||||
- 有参数任务必须实现 `task.PayloadValidator`,负责校验和标准化 Admin 下发参数。
|
||||
- `Execute` 必须再次解析 payload;不要假设入口一定经过 Admin 校验。
|
||||
- 成功返回 `&task.TaskResult{Message: ..., Detail: ...}`。
|
||||
- 失败返回 error,由任务框架处理状态和重试。
|
||||
- 不要吞掉关键错误。
|
||||
- 持久化只通过 `internal/repository/`(唯一入口);业务编排放模块内 `logics.go` / `service.go`。`internal/model` 仅实体/DTO,禁止 CRUD 与 DB 访问。
|
||||
|
||||
```go
|
||||
package order
|
||||
### 注册
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
- 在 `internal/infra/task/handlers/register.go` 同时注册 Handler 和 `TaskMeta`。
|
||||
- 不要在其他位置单独注册任务。
|
||||
- **禁止**在业务包 `routers.go` 或 `init()` 中调用 `task.RegisterHandler`;统一由 `bootstrap.RegisterTasks()` → `taskhandlers.Register()` 在进程启动时装配。
|
||||
- 任务完成钩子(如 push 通知)通过 `task.OnTaskCompleted` 注册,在 `bootstrap.RegisterTaskListeners()` 中装配(Worker/`all` 进程)。
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
### 进程装配分工
|
||||
|
||||
const (
|
||||
TaskTypeOrderTimeoutCancel = "order:timeout_cancel"
|
||||
)
|
||||
| 进程 | 注册入口 |
|
||||
| :--- | :--- |
|
||||
| `api` | `cmd/api.go` → `bootstrap.RegisterAPI()`(含 `RegisterTasks`) |
|
||||
| `worker` | `worker.StartWorker()` → `bootstrap.RegisterWorker()`(含 `RegisterTasks` + `RegisterTaskListeners`) |
|
||||
| `scheduler` | `scheduler.StartScheduler()` → `bootstrap.RegisterScheduler()` |
|
||||
| `all` | `cmd/all.go` → `bootstrap.RegisterAll()` |
|
||||
|
||||
// OrderTimeoutPayload 定义任务入参
|
||||
type OrderTimeoutPayload struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
```
|
||||
所有 `Register*` 使用 `sync.Once`,重复调用安全。
|
||||
|
||||
### 步骤 2:实现任务执行处理器 (Handler)
|
||||
### 测试
|
||||
|
||||
Handler 必须接受 `ctx context.Context, t *asynq.Task`,返回 `error`:
|
||||
- 依赖已注册任务类型或 Handler 的测试(如 `internal/apps/admin/task/routers_test.go`),必须在 setup 中显式调用 `bootstrap.RegisterTasks()`。
|
||||
- 不得依赖 `init()` 副作用或 import 链触发注册。
|
||||
|
||||
```go
|
||||
func (p *Plugin) handleOrderTimeoutCancel(ctx context.Context, t *asynq.Task) error {
|
||||
var payload OrderTimeoutPayload
|
||||
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
return err // 反序列化失败,直接中断
|
||||
}
|
||||
## 日志要求
|
||||
|
||||
// 记录任务日志
|
||||
// task.AppendLog(ctx, "开始处理订单超时关单: order_id=%s", payload.OrderID)
|
||||
- 在 `TaskHandler.Execute` 中使用 `task.AppendLog(ctx, format, args...)`。
|
||||
- 记录任务开始、参数摘要、批次进度、关键状态、可继续错误和完成摘要。
|
||||
- 批量处理按批次记录;禁止为大循环中的每条数据写日志。
|
||||
- 不要直接修改任务日志的 Redis key 或 `w_task_executions.log`。
|
||||
|
||||
// 执行业务逻辑
|
||||
if err := p.svc.CancelTimeoutOrder(ctx, payload.OrderID, payload.Reason); err != nil {
|
||||
// 返回 error 触发 Asynq 框架自动重试
|
||||
return err
|
||||
}
|
||||
日志框架约束:
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
- 执行状态实时写入数据库:`pending`、`running`、`succeeded`、`failed`。
|
||||
- 实时日志写入 Redis,每个任务最多保留最近 1000 行。
|
||||
- Redis 日志 TTL 为 24 小时,每次追加时刷新。
|
||||
- 查询时优先返回 Redis 日志,Redis 不存在时读取数据库。
|
||||
- 任务成功或自动重试耗尽后,将日志写入数据库并删除 Redis 缓冲。
|
||||
- 自动重试期间保留同一 taskID 的 Redis 日志。
|
||||
|
||||
### 步骤 3:在插件 `Apply` 中注册任务与定时调度
|
||||
## 重试要求
|
||||
|
||||
```go
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. 注册异步任务处理器
|
||||
ctx.Task().Register(
|
||||
TaskTypeOrderTimeoutCancel,
|
||||
p.handleOrderTimeoutCancel,
|
||||
extpoints.WithTaskRetry(3),
|
||||
extpoints.WithTaskTimeout(5*time.Minute),
|
||||
)
|
||||
- Handler 返回 error 以触发 Asynq 自动重试。
|
||||
- 不要在 Handler 内自行实现重复重试循环。
|
||||
- Admin 手动重试只允许:
|
||||
- 原任务状态为 `failed`
|
||||
- `Retryable=true`
|
||||
- `RetryCount < MaxRetry`
|
||||
- 修改重试行为时同时检查:
|
||||
- `internal/infra/task/executor.go`
|
||||
- `internal/model/task_execution.go`
|
||||
- `internal/apps/admin/task/routers.go`
|
||||
- 前端任务执行列表
|
||||
|
||||
// 2. 注册定时调度任务 (例如每天凌晨 2 点执行汇总)
|
||||
ctx.Schedule().RegisterCron(
|
||||
"0 2 * * *",
|
||||
"order:daily_settlement",
|
||||
map[string]any{"scope": "all"},
|
||||
)
|
||||
## 定时任务
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
- 默认定时任务必须通过 Goose SQL 迁移写入 `schedules`。
|
||||
- PostgreSQL 和 SQLite 迁移必须同时提供。
|
||||
- 初始化 SQL 必须幂等。
|
||||
- 涉及迁移时使用 `database-migration` skill。
|
||||
|
||||
### 步骤 4:在业务逻辑中投递异步任务
|
||||
## Admin API
|
||||
|
||||
当业务需要下发延迟或异步任务时:
|
||||
- Handler 放在现有 Admin task 模块或 `internal/apps/admin/<module>/`。
|
||||
- 路由只在 `internal/router/router.go` 注册。
|
||||
- 响应保持 `{ "error_msg": "", "data": ... }`。
|
||||
- 分页数据保持 `{ "total": 0, "results": [] }`。
|
||||
- Swagger 注释必须完整;API 变化后运行 `make swagger`。
|
||||
|
||||
```go
|
||||
func (s *OrderService) EnqueueTimeoutCheck(ctx context.Context, orderID string) error {
|
||||
payloadBytes, _ := json.Marshal(OrderTimeoutPayload{
|
||||
OrderID: orderID,
|
||||
Reason: "15分钟未支付自动关单",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
## 前端
|
||||
|
||||
task := asynq.NewTask(
|
||||
TaskTypeOrderTimeoutCancel,
|
||||
payloadBytes,
|
||||
asynq.ProcessIn(15*time.Minute), // 延迟 15 分钟执行
|
||||
asynq.MaxRetry(3),
|
||||
)
|
||||
|
||||
// 投递到任务客户端
|
||||
_, err := s.taskClient.EnqueueContext(ctx, task)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 运行切面透明性 (Profile Transparency)
|
||||
|
||||
Cordis 微内核支持多种启动切面(`api`、`worker`、`schedule`、`all`):
|
||||
- 插件开发者**无需在插件代码中编写 `if mode == "worker"` 分支**。
|
||||
- 插件只需在 `Apply` 中把任务与调度注册进 `Context`。
|
||||
- 当进程以 `worker` 切面启动时,微内核的 `driver_asynq_worker` 驱动会自动拾取并监听已注册的任务。
|
||||
- 当进程以 `schedule` 切面启动时,`driver_asynq_cron` 驱动会自动启动调度器引擎。
|
||||
|
||||
---
|
||||
|
||||
## 4. 任务日志与重试规范
|
||||
|
||||
1. **日志记录**:
|
||||
- 记录任务启动参数摘要、分批处理进度及最终完成统计。
|
||||
- 大循环处理中应按批次记录日志,禁止每条数据单独打日志刷屏。
|
||||
2. **重试机制**:
|
||||
- Handler 返回 error 即自动触发 Asynq 重试策略。
|
||||
- 禁止在 Handler 内部编写裸 `for` 死循环重试。
|
||||
3. **幂等性保障**:
|
||||
- 任务由于网络波动或超时可能被重复消费,业务操作必须实现幂等保护(如基于订单状态机检查或分布式锁 `ctx.DistLock()`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 质量验证
|
||||
|
||||
```bash
|
||||
make format
|
||||
make code-check
|
||||
go test ./plugins/...
|
||||
```
|
||||
- 仅任务元数据变化时,优先复用现有动态任务表单,不新增页面。
|
||||
- API 调用必须通过 `frontend/lib/services/`。
|
||||
- 修改 shadcn/ui 时使用 `shadcn` skill。
|
||||
- 不使用 `any`。
|
||||
- 页面根容器使用 `w-full`,不添加页面级 `max-w-*`。
|
||||
|
||||
@@ -1,154 +1,277 @@
|
||||
# Wavelet 异步任务代码示例 (Cordis 插件化架构)
|
||||
# Wavelet 异步任务代码示例
|
||||
|
||||
这些示例用于在 Cordis 插件中开发或修改 Asynq 任务时快速套用。
|
||||
这些示例用于新增或修改 Wavelet Asynq 任务时快速套用。复制前先对照当前代码,因为任务框架可能随项目演进。
|
||||
|
||||
---
|
||||
## 任务元数据与常量定义
|
||||
|
||||
## 1. 任务定义与 Handler 编写
|
||||
在对应的业务包 `internal/apps/<module>/tasks.go` 中定义 Asynq task type、Admin task type 和 `TaskMeta`。
|
||||
|
||||
在对应的业务插件中(如 `backend/plugins/domain/user/tasks.go`):
|
||||
```go
|
||||
package upload
|
||||
|
||||
import (
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
// 异步任务类型标识。格式建议为 "{module}:{action}"。
|
||||
const CleanupUnusedUploadsTask = "upload:cleanup_unused"
|
||||
|
||||
// 管理员可下发的任务类型标识。用于 Admin API 的 task_type。
|
||||
const TaskTypeCleanupUploads = "cleanup_unused_uploads"
|
||||
|
||||
// CleanupUnusedUploadsMeta 任务元数据
|
||||
var CleanupUnusedUploadsMeta = task.TaskMeta{
|
||||
Type: TaskTypeCleanupUploads,
|
||||
AsynqTask: CleanupUnusedUploadsTask,
|
||||
Name: "清理未使用上传",
|
||||
Description: "清理超过1小时未使用的上传文件",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
Retryable: true,
|
||||
}
|
||||
```
|
||||
|
||||
带参数任务把前端表单元数据放在 `Params`。`Name` 必须和 payload JSON tag 对齐。
|
||||
|
||||
```go
|
||||
{
|
||||
Type: TaskTypeSendEmail,
|
||||
AsynqTask: SendEmailTask,
|
||||
Name: "发送邮件",
|
||||
Description: "异步发送系统邮件",
|
||||
SupportsTime: false,
|
||||
MaxRetry: defaultMaxRetry,
|
||||
Queue: QueueDefault,
|
||||
Retryable: true,
|
||||
Params: []TaskParam{
|
||||
{
|
||||
Name: "to",
|
||||
Label: "接收邮箱 (To)",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Placeholder: "receiver@example.com",
|
||||
Description: "接收邮件的目标邮箱地址",
|
||||
},
|
||||
{
|
||||
Name: "subject",
|
||||
Label: "邮件主题 (Subject)",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Placeholder: "请输入邮件主题",
|
||||
Description: "发送邮件的主题标题",
|
||||
},
|
||||
{
|
||||
Name: "body",
|
||||
Label: "邮件内容 (Body)",
|
||||
Type: "text",
|
||||
Required: true,
|
||||
Placeholder: "请输入邮件内容",
|
||||
Description: "发送邮件的内容主体",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 无参数 Handler
|
||||
|
||||
放在对应业务模块,例如 `internal/apps/upload/tasks.go`。
|
||||
|
||||
```go
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
type CleanupUnusedUploadsHandler struct{}
|
||||
|
||||
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
task.AppendLog(ctx, "开始扫描未使用上传")
|
||||
|
||||
// 调用 model/service 完成业务逻辑。
|
||||
// 批量处理时按批次记录日志,不要每条记录都 AppendLog。
|
||||
|
||||
msg := "清理完成"
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 带参数 Handler
|
||||
|
||||
实现 `PayloadValidator` 做 Admin 下发时的服务端校验和标准化。`Execute` 仍然解析 payload,因为 Scheduler 和 Retry 不一定经过 Admin 校验路径。
|
||||
|
||||
```go
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
// 异步任务类型标识。格式推荐为 "{plugin}:{action}"
|
||||
const TaskTypeSendEmail = "user:send_email"
|
||||
|
||||
type SendEmailPayload struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// Handler 处理函数
|
||||
func (p *Plugin) handleSendEmail(ctx context.Context, t *asynq.Task) error {
|
||||
var req SendEmailPayload
|
||||
if err := json.Unmarshal(t.Payload(), &req); err != nil {
|
||||
return fmt.Errorf("解析任务参数: %w", err)
|
||||
}
|
||||
type SendEmailHandler struct{}
|
||||
|
||||
req.To = strings.TrimSpace(req.To)
|
||||
req.Subject = strings.TrimSpace(req.Subject)
|
||||
req.Body = strings.TrimSpace(req.Body)
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
return errors.New("to、subject、body 不能为空")
|
||||
}
|
||||
func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("任务参数不能为空")
|
||||
}
|
||||
|
||||
// 执行实际邮件发送业务逻辑
|
||||
return p.emailSvc.Send(ctx, req.To, req.Subject, req.Body)
|
||||
var req SendEmailPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("无效的 JSON 格式: %w", err)
|
||||
}
|
||||
|
||||
req.To = strings.TrimSpace(req.To)
|
||||
req.Subject = strings.TrimSpace(req.Subject)
|
||||
req.Body = strings.TrimSpace(req.Body)
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
return nil, errors.New("to、subject、body 不能为空")
|
||||
}
|
||||
|
||||
return json.Marshal(req)
|
||||
}
|
||||
|
||||
func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
var req SendEmailPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("解析任务参数: %w", err)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "开始发送邮件到: %s", req.To)
|
||||
|
||||
// 调用业务服务发送邮件。
|
||||
|
||||
msg := fmt.Sprintf("邮件成功发送至: %s", req.To)
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## 统一注册
|
||||
|
||||
## 2. 插件内自包含注册 (`Apply`)
|
||||
|
||||
在插件的 `Apply(ctx *core.Context)` 中:
|
||||
在 `internal/infra/task/handlers/register.go` 注册。Admin dispatch 的 `ValidateAndNormalizePayload` 和 Worker 执行都依赖这里。
|
||||
|
||||
```go
|
||||
package user
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
"OpenFlare/internal/apps/upload"
|
||||
"OpenFlare/internal/apps/user"
|
||||
"OpenFlare/internal/infra/task"
|
||||
)
|
||||
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. 注册 Asynq 异步任务消费处理器
|
||||
ctx.Task().Register(
|
||||
TaskTypeSendEmail,
|
||||
p.handleSendEmail,
|
||||
extpoints.WithTaskRetry(3),
|
||||
extpoints.WithTaskTimeout(2*time.Minute),
|
||||
)
|
||||
|
||||
// 2. 注册 Cron 调度任务(例如每天凌晨 3 点清理过期 Token)
|
||||
ctx.Schedule().RegisterCron(
|
||||
"0 3 * * *",
|
||||
"user:cleanup_expired_tokens",
|
||||
map[string]any{"scope": "expired"},
|
||||
)
|
||||
|
||||
return nil
|
||||
func Register() {
|
||||
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
|
||||
task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## Cron 调度和配置
|
||||
|
||||
## 3. 业务中投递异步任务
|
||||
系统默认的定时任务必须通过 Goose SQL 迁移初始化插入到 `schedules` 表。
|
||||
|
||||
在 `internal/infra/persistence/migrator/goose/postgres` 下的示例:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
|
||||
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
-- 根据业务需求决定是否需要在此删除
|
||||
```
|
||||
|
||||
对于 `sqlite` 也可以使用类似的 `INSERT INTO ... ON CONFLICT(id) DO NOTHING` 语法。数据库更新后,后端会自动热重载调度器。
|
||||
|
||||
## Handler 测试
|
||||
|
||||
带参数任务至少覆盖合法 payload、空 payload、非法 JSON、缺失必填和标准化。
|
||||
|
||||
```go
|
||||
func (s *UserService) TriggerWelcomeEmail(ctx context.Context, toEmail, username string) error {
|
||||
payload, _ := json.Marshal(SendEmailPayload{
|
||||
To: toEmail,
|
||||
Subject: "欢迎加入",
|
||||
Body: fmt.Sprintf("你好 %s,欢迎使用我们的平台!", username),
|
||||
})
|
||||
func TestSendEmailHandlerValidatePayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
want SendEmailPayload
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid payload is normalized",
|
||||
payload: []byte(`{"to":" user@example.com ","subject":" hi ","body":" body "}`),
|
||||
want: SendEmailPayload{
|
||||
To: "user@example.com",
|
||||
Subject: "hi",
|
||||
Body: "body",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty payload",
|
||||
payload: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
payload: []byte(`{`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing required field",
|
||||
payload: []byte(`{"to":"user@example.com","subject":"","body":"body"}`),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
task := asynq.NewTask(
|
||||
TaskTypeSendEmail,
|
||||
payload,
|
||||
asynq.MaxRetry(3),
|
||||
)
|
||||
h := &SendEmailHandler{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotPayload, err := h.ValidatePayload(tt.payload)
|
||||
if gotErr := err != nil; gotErr != tt.wantErr {
|
||||
t.Fatalf("ValidatePayload(%s) error = %v, want error presence = %t", tt.payload, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.taskClient.EnqueueContext(ctx, task)
|
||||
return err
|
||||
var got SendEmailPayload
|
||||
if err := json.Unmarshal(gotPayload, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal(%s) error = %v", gotPayload, err)
|
||||
}
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Errorf("ValidatePayload(%s) mismatch (-want +got):\n%s", tt.payload, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
`Execute` 测试优先验证业务服务调用、错误返回和结果摘要;日志可只验证关键路径,避免把精确日志文本写成脆弱断言。
|
||||
|
||||
## 4. 任务处理函数单元测试
|
||||
## Admin Dispatch 测试形状
|
||||
|
||||
Admin dispatch 测试关注通用链路是否调用了 `PayloadValidator`,不要为每种任务在 handler 里写 if 分支。
|
||||
|
||||
```go
|
||||
func TestSendEmailPayloadValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid payload",
|
||||
payload: []byte(`{"to":"user@example.com","subject":"hi","body":"welcome"}`),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty payload",
|
||||
payload: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing required fields",
|
||||
payload: []byte(`{"to":"user@example.com","subject":""}`),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req SendEmailPayload
|
||||
err := json.Unmarshal(tt.payload, &req)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(req.To) == "" || strings.TrimSpace(req.Subject) == "" || strings.TrimSpace(req.Body) == "" {
|
||||
err = errors.New("missing fields")
|
||||
}
|
||||
}
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validation error = %v, wantErr = %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
func TestDispatchTaskValidatesPayload(t *testing.T) {
|
||||
// 1. 初始化测试 DB 和 task.AsynqClient。
|
||||
// 2. 注册测试 handler: task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
|
||||
// 3. POST /api/v1/admin/tasks/dispatch,传入非法 payload。
|
||||
// 4. 断言响应为 400,错误信息清晰,且没有创建可执行任务。
|
||||
}
|
||||
```
|
||||
|
||||
需要 Redis/Asynq 时优先复用项目现有测试模式;没有现成依赖时可用 `miniredis` 初始化 `task.AsynqClient`。不要把 `internal/infra/task` 依赖塞进通用 testhelper 造成 import cycle。
|
||||
|
||||
@@ -1,122 +1,161 @@
|
||||
---
|
||||
name: "new-setting"
|
||||
description: "Wavelet 项目专用:当新增或修改基于 Cordis 插件的静态配置文件绑定、动态系统/业务设置声明 (ctx.Settings)、管理台热加载设置或前端公共配置消费逻辑时必须使用。"
|
||||
description: "Wavelet 项目专用:当新增或修改启动时设置、数据库系统设置、业务设置、公共可见配置、/admin/system 参数配置、/admin/settings 图形化设置界面,或前端公共配置消费逻辑时必须使用。本技能指导设置类型判定、SystemConfig 字段与 visibility、goose SQL 初始化/升级、热更新读取、公共配置暴露、shadcn 图形组件和验证流程。"
|
||||
---
|
||||
|
||||
# 插件配置与动态系统设置开发规范 (Cordis 插件化架构)
|
||||
# 新增设置项
|
||||
|
||||
本技能覆盖 Wavelet 在 Cordis 架构下的静态与动态设置体系。
|
||||
本技能覆盖 Wavelet 的设置体系。开始前先读仓库根目录 `AGENTS.md`,遵守项目级规则:HTTP 路由只在 `internal/router/router.go` 注册、API 变更后运行 `make swagger`、提交前运行 `make code-check`、不要删除 `frontend/node_modules`、`internal/util/` 不引入框架依赖。
|
||||
|
||||
---
|
||||
如果需要在 `/admin/settings` 增加或调整图形化设置组件,同时阅读 [shadcn](../shadcn/SKILL.md)。如果只是新增 Go 读取逻辑、测试或错误处理,再按需阅读对应 `go-*` skill。
|
||||
|
||||
## 1. 两种配置模式与选型
|
||||
## 先判定设置类型
|
||||
|
||||
Wavelet 提供两种维度的配置能力:
|
||||
Wavelet 当前有两套设置入口:
|
||||
|
||||
| 模式 | 机制 | 适用场景 | 注册/读取方式 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **静态启动配置** | `config.yaml` / 环境变量 | 进程启动前必须确定、不热更新的配置(如第三方 API Key、端口、物理路径) | `ctx.Config().Bind("plugins.<name>", &cfg)` |
|
||||
| **动态系统设置** | 数据库持久化 + 缓存 + 热加载 | 运行时可被管理员在管理控制台动态修改的业务规则、开关、阈值 | `ctx.Settings().Register(SettingSchema{...})` |
|
||||
- 启动时设置:来自 `config.yaml` 或环境变量,适合进程启动前必须确定、通常不热更新的基础配置。
|
||||
- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 实体(key 常量在 model)与 `repository` 读取层(含 Redis hash 缓存)访问,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。
|
||||
|
||||
---
|
||||
系统设置分三种使用语义:
|
||||
|
||||
## 2. 插件内配置声明与绑定
|
||||
- 业务设置:`type=business`,由管理员配置,影响业务规则,例如用户额度、业务限制。
|
||||
- 系统设置:`type=system`,由管理员配置,影响平台能力、基础开关、外部服务参数。
|
||||
- 公共可见配置:附加在业务设置或系统设置之上,由 `visibility=1` 控制是否通过公开接口返回给前端使用。它不是第三种数据库 `type`,不要把 `type` 写成 `public`。
|
||||
|
||||
### 2.1 静态配置声明与绑定 (`DeclareConfig` 与 `ctx.Config().Bind`)
|
||||
业务设置和系统设置互斥:一个配置项只能选择 `business` 或 `system`。是否公开给前端由 `visibility` 决定:`0` 表示隐藏,`1` 表示 `/api/v1/config/public` 可见。
|
||||
|
||||
静态启动配置遵循插件自包含声明与解耦规范:
|
||||
特殊设置组件不一定需要新增 `SystemConfig` 参数项。例如认证源设置、模板管理这类有独立模型和 API 的功能,应沿用对应领域模型,不要为了出现在 `/admin/settings` 强行创建参数配置。
|
||||
|
||||
```go
|
||||
type OrderStaticConfig struct {
|
||||
PaymentGatewayURL string `config:"payment_gateway_url" env:"ORDER_PAYMENT_URL" default:"https://pay.example.com"`
|
||||
TimeoutSeconds int `config:"timeout_seconds" env:"ORDER_TIMEOUT" default:"30"`
|
||||
ApiKey string `config:"api_key" env:"ORDER_API_KEY" secret:"true"`
|
||||
}
|
||||
## 先定位真实链路
|
||||
|
||||
// 可选:实现 DeclareConfig 声明配置模式(若需门禁求值则实现 core.ConfigGatedPlugin)
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "plugins.order", Target: &OrderStaticConfig{}},
|
||||
}
|
||||
}
|
||||
修改前快速查看这些文件,确认当前实现没有漂移:
|
||||
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg OrderStaticConfig
|
||||
// 从统一配置源绑定 plugins.order 节点配置(支持 YAML 与环境变量覆盖)
|
||||
_ = ctx.Config().Bind("plugins.order", &cfg)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
- `internal/model/system_configs.go`: 配置 key 常量(`ConfigKey*`)、`SystemConfig` 实体与字段语义;**不含**持久化读取 API。
|
||||
- `internal/repository/system_config.go`: 配置读取与缓存(`GetSystemConfigByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey`、`ListVisibleSystemConfigs` 等)。
|
||||
- `internal/infra/persistence/migrator/goose/postgres/*.sql` 和 `internal/infra/persistence/migrator/goose/sqlite/*.sql`: `system_configs` 表结构、初始化 seed、后续升级迁移。
|
||||
- `internal/infra/persistence/migrator/migrator.go`: goose 迁移入口和 PostgreSQL/SQLite 方言选择。
|
||||
- `internal/testhelper/test_helper.go`: Go 测试用默认系统配置 seed。
|
||||
- `internal/apps/admin/system_config/routers.go`: `/api/v1/admin/system-configs` 参数表 API。
|
||||
- `internal/apps/config/routers.go`: `/api/v1/config/public` 公共配置响应。
|
||||
- `frontend/components/common/admin/system.tsx`: `/admin/system` 参数表管理界面,展示所有参数配置项。
|
||||
- `frontend/components/common/settings/system-settings.tsx`: `/admin/settings` 图形化设置页入口。
|
||||
- `frontend/components/common/settings/*-tab.tsx`: `/admin/settings` 各图形化设置分组。
|
||||
- `frontend/lib/services/admin/*`: Admin 系统配置 service 类型和 API 封装。
|
||||
- `frontend/lib/services/config/*`、`frontend/hooks/use-public-config`、`frontend/components/layout/*`: 前端公共配置消费链路。
|
||||
|
||||
### 2.2 动态设置注册 (`ctx.Settings().Register`)
|
||||
## 新增数据库系统设置
|
||||
|
||||
插件在 `Apply` 中声明其支持动态调节的 Schema:
|
||||
按影响面选择步骤,不要只改 UI 或只改默认值。
|
||||
|
||||
```go
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. 注册内部业务规则设置
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "order.auto_cancel_mins",
|
||||
Default: 15,
|
||||
Description: "未支付订单自动取消时间 (分钟)",
|
||||
Category: "business",
|
||||
Public: false,
|
||||
})
|
||||
1. 定义配置 key。
|
||||
- 在 `internal/model/system_configs.go` 添加 `ConfigKey...` 常量。
|
||||
- key 使用 lowercase snake case,例如 `search_engine_indexing_enabled`。
|
||||
- 值仍存为字符串;布尔值用 `"true"` / `"false"`,数值用十进制字符串,复杂结构用 JSON 字符串。
|
||||
|
||||
// 2. 注册前端公共可见开关 (Public: true)
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "order.invoice_enabled",
|
||||
Default: true,
|
||||
Description: "是否开启订单电子发票开具功能",
|
||||
Category: "business",
|
||||
Public: true, // 允许前端通过 /api/v1/config/public 匿名读取
|
||||
})
|
||||
2. 初始化默认配置。
|
||||
- 如果修改初始 schema,必须同步 `internal/infra/persistence/migrator/goose/postgres/` 和 `internal/infra/persistence/migrator/goose/sqlite/` 中的 goose SQL。
|
||||
- 既有库新增配置时,新增一组时间戳递增的双 SQL 迁移文件,分别放在 PostgreSQL 和 SQLite 目录;不要回到 GORM AutoMigrate 或 Go 代码 seed。
|
||||
- 新库初始化也需要包含同一个默认 key:当前初始 seed 在 `202606090001_initial_schema.sql` 的 `INSERT INTO system_configs (...) VALUES ... ON CONFLICT (key) DO NOTHING`。
|
||||
- 设置正确的 `Type`:只能是 `"system"` 或 `"business"`。
|
||||
- 设置正确的 `Visibility`:公共可见填 `1`,内部配置填 `0`。
|
||||
- 默认值要和 Go 读取侧的零值或兜底值一致,避免首次启动和数据库缺失时行为不同。
|
||||
- 如果相关 Go 包测试依赖默认配置,同步 `internal/testhelper/test_helper.go` 的 `seedDefaultConfigs` 和公共 key 列表。
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
3. 读取配置。
|
||||
- 后端业务代码通过 `internal/repository` 读取:`repository.GetBoolByKey`、`repository.GetIntByKey`、`repository.GetDecimalByKey` 或 `repository.GetSystemConfigByKey`;key 常量仍用 `model.ConfigKey*`。
|
||||
- 禁止新增或调用 `model.Get*ByKey` / `model.ListVisibleSystemConfigs` 等数据访问 API(model 无 CRUD)。
|
||||
- 运行时可热更新的规则不要放进 `config.Config`;启动时设置才走 `internal/infra/config/model.go` 和 `config.example.yaml`。
|
||||
- 不要在 handler 或业务代码里直接读 `os.Getenv()`。
|
||||
|
||||
---
|
||||
4. 如果前端需要未登录或全局消费,暴露为公共可见配置。
|
||||
- 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `repository.ListVisibleSystemConfigs` 返回所有可见 key/value。
|
||||
- `/api/v1/config/public` 的 `data` 是动态对象:后端返回 `map[string]string`,前端类型是 `Record<string, string | undefined>`。
|
||||
- 前端读取时按配置 key 访问,必要时在消费侧把字符串转换为 boolean/number/JSON。
|
||||
- 检查使用方的 query key,更新后需要 invalidate `["public-config"]`。
|
||||
- 只有公共配置 API 形状或注释变化时才需要更新 Swagger;单纯新增 `visibility=1` 的 key 通常不需要改 `PublicConfigResponse` 类型。
|
||||
|
||||
## 3. Schema 核心属性说明
|
||||
5. 如果管理员需要图形化配置,更新 `/admin/settings`。
|
||||
- 先阅读 shadcn skill。
|
||||
- 根据设置语义选择现有 tab:安全类进 `security-tab.tsx`,运营类进 `operation-tab.tsx`,系统基础参数进 `system-tab.tsx`,其它菜单或杂项进 `other-tab.tsx`。
|
||||
- `SystemSettingsMain` 当前通过 `AdminService.listSystemConfigs("system")` 只加载 `type=system` 的配置;`type=business` 的配置若也需要图形化入口,先确认是否要调整查询范围或放到其它 Admin 页面。
|
||||
- 新的图形组件优先放在 `frontend/components/common/settings/`,使用现有 `AdminService.updateSystemConfig`。
|
||||
- 更新成功后 invalidate `["admin", "system-configs"]`;公共可见配置还要 invalidate `["public-config"]`。
|
||||
- 使用 Sonner toast 反馈成功或失败。
|
||||
- 不使用 `any`,不要硬编码页面级 `max-w-*`,页面根容器保持 `w-full`。
|
||||
|
||||
- **`Key`**:全局唯一配置键名,推荐小写点分蛇形命名(如 `domain.setting_name`)。
|
||||
- **`Default`**:默认值(支持 `bool`、`int`、`string`、`JSON 结构`)。
|
||||
- **`Category`**:
|
||||
- `"business"`:业务规则、用户额度、流程开关。
|
||||
- `"system"`:系统底层调优、平台安全参数。
|
||||
- **`Public`**:布尔值。若为 `true`,会自动暴露至 `/api/v1/config/public`,供前端未登录或全局消费。
|
||||
- **`ReadOnly`**:若为 `true`,管理台仅做展示,禁止通过 API 修改。
|
||||
6. `/admin/system` 参数表通常不需要新代码。
|
||||
- 只要 `SystemConfig` 默认数据存在,参数表会展示配置项。
|
||||
- `/admin/system` 偏向所有参数配置项的键值管理,不替代 `/admin/settings` 的友好图形界面。
|
||||
|
||||
---
|
||||
## 新增启动时设置
|
||||
|
||||
## 4. 前端消费与管理台界面
|
||||
只有在配置必须随进程启动确定、不能或不应热更新时,才走启动时设置。
|
||||
|
||||
### 4.1 前端公共配置消费
|
||||
当设置声明为 `Public: true` 时,前端可使用 `usePublicConfig` hook 消费:
|
||||
1. 在 `internal/infra/config/model.go` 添加配置字段。
|
||||
2. 在 `config.example.yaml` 添加示例值和说明。
|
||||
3. 确认 Viper 现有加载逻辑能绑定该字段;需要环境变量时沿用当前命名和绑定方式。
|
||||
4. 运行时代码从 `config.Config.<Section>.<Field>` 读取。
|
||||
5. 不要把启动时设置同步塞进 `SystemConfig`,除非产品明确需要运行时覆盖。
|
||||
|
||||
```tsx
|
||||
import { usePublicConfig } from "@/hooks/use-public-config";
|
||||
## 常见模式
|
||||
|
||||
export function InvoiceButton() {
|
||||
const { data: config } = usePublicConfig();
|
||||
const invoiceEnabled = config?.["order.invoice_enabled"] === "true";
|
||||
### 布尔公共设置
|
||||
|
||||
if (!invoiceEnabled) return null;
|
||||
return <Button>申请开票</Button>;
|
||||
}
|
||||
```
|
||||
- model key:`ConfigKeyFeatureEnabled = "feature_enabled"`(定义在 `internal/model`)
|
||||
- goose SQL 默认值:`value='false'`,`type` 按语义选 `"system"` 或 `"business"`,`visibility=1`。
|
||||
- 后端读取:`repository.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。
|
||||
- 公共响应:`/api/v1/config/public` 的 `data.feature_enabled` 为字符串 `"true"` 或 `"false"`。
|
||||
- 前端图形控件:`Switch`,保存时写 `"true"` / `"false"`。
|
||||
|
||||
### 4.2 管理后台热加载设置 (`/admin/settings` 与 `/admin/system`)
|
||||
- **`/admin/system`**:通用参数表,自动根据所有已注册的 `SettingSchema` 渲染全量配置项的读写管理。
|
||||
- **`/admin/settings`**:图形化设置面板。如需在特定的 Tab 中提供高体验的开关/输入组件,参考 `shadcn` 技能使用标准组件进行开发,并通过 `AdminService.updateSystemConfig` 更新。
|
||||
### 数值业务设置
|
||||
|
||||
---
|
||||
- model key:`ConfigKeyMaxSomething = "max_something"`。
|
||||
- goose SQL 默认值:例如 `"5"`,`type` 通常为 `"business"`,只有前端公共消费时才设 `visibility=1`。
|
||||
- 后端读取:`repository.GetIntByKey` 或 `repository.GetDecimalByKey`。
|
||||
- 前端图形控件:`Input type="number"` 或合适的 shadcn 数值控件;保存前做最小必要校验,错误用 toast。
|
||||
|
||||
## 5. 质量与验证门禁
|
||||
### JSON 设置
|
||||
|
||||
- 默认值使用合法 JSON,例如 `"{}"` 或 `"[]"`。
|
||||
- 在 repository 或业务 logics 中提供解析函数,像 `repository.GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误;不要在 model 中做 IO。
|
||||
- 前端不要直接拼接 JSON 字符串;用 `JSON.stringify` 写入,用类型化对象在组件中操作。
|
||||
|
||||
## 验证
|
||||
|
||||
根据改动范围运行最小有效验证,最后提交前必须运行项目门禁。
|
||||
|
||||
- 新增或修改系统配置默认值、visibility 或公共配置读取:至少运行相关 Go 包测试,例如:
|
||||
|
||||
```bash
|
||||
make format
|
||||
make code-check
|
||||
go test ./plugins/...
|
||||
go test ./internal/repository ./internal/apps/config ./internal/apps/admin/system_config
|
||||
```
|
||||
|
||||
- 新增 goose 迁移后,至少用当前数据库方言跑一次迁移;如果 SQL 同时改了 PostgreSQL 和 SQLite,尽量覆盖两种方言。涉及 schema/seed 的任务还应遵循 database-migration skill。
|
||||
|
||||
- 公共配置 API 注释或 handler 签名改动后:
|
||||
|
||||
```bash
|
||||
make swagger
|
||||
```
|
||||
|
||||
- 前端图形设置改动后:
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- 提交前:
|
||||
|
||||
```bash
|
||||
make code-check
|
||||
```
|
||||
|
||||
如涉及前端页面体验,启动本地服务并用浏览器验证 `/admin/settings` 和 `/admin/system`:配置能显示、保存、toast 反馈正常、刷新后值保持、公共配置消费方能即时或刷新后生效。
|
||||
|
||||
## 相关 Skills
|
||||
|
||||
- shadcn:新增或调整 `/admin/settings` 图形化设置组件时使用。
|
||||
- database-migration:新增或修改 `system_configs` schema、默认 seed 或 goose SQL 迁移时使用。
|
||||
- go-error-handling:配置解析、缺失配置、非法值错误需要跨包返回时使用。
|
||||
- go-testing:为配置读取、公共配置 API 或 Admin 配置 API 添加测试时使用。
|
||||
- go-context:配置读取在请求链路或后台链路中传递取消和超时时使用。
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Wavelet 项目专用:当需要开发或接入新的系统通知
|
||||
|
||||
# 新增消息推送与通知事件开发规范
|
||||
|
||||
本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](../../../AGENTS.md),遵守项目级核心规则。
|
||||
本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。
|
||||
|
||||
---
|
||||
|
||||
@@ -15,9 +15,9 @@ Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异
|
||||
|
||||
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`backend/plugins/domain/message_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
|
||||
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 |
|
||||
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 |
|
||||
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
|
||||
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 |
|
||||
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 统一装配,禁止 `init()` 副作用。 |
|
||||
| **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 |
|
||||
| **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 |
|
||||
| **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`w_push_histories` 存放消息发送记录用于审计。 |
|
||||
@@ -38,8 +38,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
||||
"github.com/Rain-kl/Wavelet/pkg/listener"
|
||||
"OpenFlare/internal/apps/admin/push"
|
||||
"OpenFlare/internal/listener"
|
||||
)
|
||||
|
||||
var NewUserRegistered = push.EventMetadata{
|
||||
@@ -68,8 +68,8 @@ func handleUserRegistered(ctx context.Context, event listener.UserRegistered) {
|
||||
> `EventTrigger.Trigger` 已内置异步 Goroutine 与 `context.WithoutCancel`;处理函数内直接调用即可,无需外层 `go func()`。
|
||||
|
||||
### 步骤 2:在 `listener/` 定义域事件并在 `register.go` 装配
|
||||
1. 在 `internal/listener/` 新增域事件类型、`Emit*` 与 `On*` 注册函数(参考 `internal/listener/admin_login.go`)。
|
||||
2. 在 `register.go` 中注册元数据并订阅域事件:
|
||||
1. 在 `internal/listener/` 新增域事件类型、`Emit*` 与 `On*` 注册函数(参考 [admin_login.go](file:///Users/ryan/DEV/Go/Wavelet/internal/listener/admin_login.go))。
|
||||
2. 在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 中注册元数据并订阅域事件:
|
||||
|
||||
```go
|
||||
func Register() {
|
||||
@@ -84,7 +84,7 @@ func Register() {
|
||||
在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件:
|
||||
|
||||
```go
|
||||
import "github.com/Rain-kl/Wavelet/pkg/listener"
|
||||
import "OpenFlare/internal/listener"
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
// ... 注册成功逻辑 ...
|
||||
@@ -104,7 +104,7 @@ func Register(c *gin.Context) {
|
||||
`Init` 中的 `SyncEvents` 会将 `user_registered` 元数据同步到 `w_push_events`,管理员即可在前端配置推送渠道。
|
||||
|
||||
### 步骤 5:编写集成测试
|
||||
在 `custom_events/` 或 `listener/` 包内添加测试,验证 `Emit*` → handler → `DefaultTrigger.Trigger` 全链路。测试 setup 须显式调用 `custom_events.Register()`(或 `bootstrap.RegisterPushDomainEvents()`)和 `push.SyncEvents`,参考 `admin_login_test.go`。
|
||||
在 `custom_events/` 或 `listener/` 包内添加测试,验证 `Emit*` → handler → `DefaultTrigger.Trigger` 全链路。测试 setup 须显式调用 `custom_events.Register()`(或 `bootstrap.RegisterPushDomainEvents()`)和 `push.SyncEvents`,参考 [admin_login_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/admin_login_test.go)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: "release-guide"
|
||||
description: "项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
|
||||
description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
|
||||
---
|
||||
|
||||
# Release Commit Message Guide
|
||||
|
||||
## 目标
|
||||
|
||||
当用户准备发布新版本时,本 Skill 负责:
|
||||
当用户准备发布 Wavelet 新版本时,本 Skill 负责:
|
||||
|
||||
1. 根据上一正式版本 Tag 以来的提交,整理面向用户的发版说明;
|
||||
2. 新建 **独立的** `chore(release): vX.Y.Z` 提交(可附带将 `docs/changelog` 从 `[unreleased]` 落版)。
|
||||
@@ -51,8 +51,8 @@ description: "项目专用:根据自上一个正式版本 Tag 以来的提交
|
||||
「修复/优化」与「新增」的判定(关键):
|
||||
|
||||
- **判定标准是“该功能在上一正式版本中是否已存在”**:
|
||||
- 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」;
|
||||
- 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。
|
||||
- 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」;
|
||||
- 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。
|
||||
- 禁止把新功能的开发期修复/优化写进「修复」或「优化」:新功能此前版本没有,谈不上“修复/优化了旧行为”。
|
||||
|
||||
示例:
|
||||
|
||||
+44
-48
@@ -1,57 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Autoresearch GUARD — hard veto. Every line here protects an invariant that is
|
||||
# unrelated to the primary metric, plus the anti-cheat red lines.
|
||||
set -uo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "${ROOT}/backend"
|
||||
# Correctness gate: must pass after every edit. Fails fast on real breakage.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Same per-checkout lint cache as measure.sh: golangci-lint's default cache is
|
||||
# machine-wide, so a different worktree analysing identical sources can make this
|
||||
# Guard read back a stale verdict. Key it to the backend directory.
|
||||
GOLANGCI_LINT_CACHE="${TMPDIR:-/tmp}/ar-lint-cache-$(pwd | cksum | awk '{print $1}')"
|
||||
export GOLANGCI_LINT_CACHE
|
||||
echo "==> go vet ./..."
|
||||
go vet ./... 2>&1 | tail -20
|
||||
|
||||
STATUS=0
|
||||
fail() { echo "GUARD FAIL: $1"; STATUS=1; }
|
||||
echo "==> go build ./..."
|
||||
go build ./... 2>&1 | tail -20
|
||||
|
||||
source "${ROOT}/.auto/baseline.env"
|
||||
echo "==> golangci-lint run (repo config)"
|
||||
golangci-lint run 2>&1 | tail -20
|
||||
|
||||
# --- 1. Correctness -----------------------------------------------------------
|
||||
go build ./... || fail "go build failed"
|
||||
go vet ./... || fail "go vet failed"
|
||||
# 全量单测(sqlite + miniredis,纯本地无需外部服务;2026-08-16 起全绿)
|
||||
echo "==> go test ./internal/... ./pkg/..."
|
||||
go test ./internal/... ./pkg/... 2>&1 | grep -E "^--- FAIL|^FAIL" | head -20 || true
|
||||
if go test ./internal/... ./pkg/... > /tmp/auto_gotest.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -30 /tmp/auto_gotest.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go test ./... > /tmp/ar_guard_test.txt 2>&1 || true
|
||||
FAILS=$(grep -cE '^(FAIL|--- FAIL)' /tmp/ar_guard_test.txt || true)
|
||||
[ "${FAILS}" = "0" ] || { grep -E '^(FAIL|--- FAIL)' /tmp/ar_guard_test.txt | head -20; fail "tests failing (${FAILS})"; }
|
||||
# 前端测试(vitest;2026-08-16 起全绿)
|
||||
echo "==> pnpm exec vitest run (frontend)"
|
||||
(cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm exec vitest run --reporter=dot > /tmp/auto_vitest.log 2>&1) || {
|
||||
tail -30 /tmp/auto_vitest.log
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 2. Cordis architecture gate ---------------------------------------------
|
||||
"${ROOT}/scripts/check_cordis_architecture.sh" > /dev/null 2>&1 || fail "cordis architecture check failed"
|
||||
# SPDX license 头门禁(repo 自带约定)
|
||||
echo "==> make license-check"
|
||||
make license-check 2>&1 | grep "needs license" | head -10 || true
|
||||
if make license-check > /tmp/auto_license.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -15 /tmp/auto_license.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- 3. Project lint gate must stay clean ------------------------------------
|
||||
PROJECT_LINT=$(golangci-lint run 2>&1 | grep -cE '\.go:[0-9]+:[0-9]+: ' || true)
|
||||
[ "${PROJECT_LINT}" = "0" ] || { fail "project golangci-lint reports ${PROJECT_LINT} issues"; }
|
||||
# 并发密集包 -race 门禁(2026-08-16 全仓 -race 清零后纳入,防回归;
|
||||
# frpc/frps 慢套件不含在此,另做全量周期验证)
|
||||
echo "==> go test -race (concurrency packages)"
|
||||
RACE_PKGS="./internal/apps/oauth/ ./internal/apps/openflare/tls/ ./internal/apps/openflare/uptimekuma/ ./internal/apps/upload/cache/ ./internal/repository/ ./pkg/cache/disk/ ./pkg/logger/ ./internal/infra/persistence/batchwriter/"
|
||||
if go test -race -count=1 $RACE_PKGS > /tmp/auto_race.log 2>&1; then
|
||||
:
|
||||
else
|
||||
grep -E "WARNING: DATA RACE|^--- FAIL|^FAIL" /tmp/auto_race.log | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- 4. Anti-cheat: the yardstick itself is immutable ------------------------
|
||||
REF_SHA_NOW=$(shasum -a 256 "${ROOT}/.auto/lint.ref.yaml" | awk '{print $1}')
|
||||
[ "${REF_SHA_NOW}" = "${REF_SHA}" ] || fail "pinned yardstick .auto/lint.ref.yaml was modified"
|
||||
|
||||
# --- 5. Anti-cheat: the project gate may only ever be STRENGTHENED ------------
|
||||
WEAK=$(python3 "${ROOT}/.auto/check_gate_weaken.py" 2>&1) || { echo "${WEAK}"; fail "project gate weakened"; }
|
||||
|
||||
# --- 6. Anti-cheat: no new suppressions --------------------------------------
|
||||
NOLINT=$(rg '//\s*nolint' --glob '*.go' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ "${NOLINT}" -le "${BASE_NOLINT}" ] || fail "nolint directives grew (${NOLINT} > ${BASE_NOLINT})"
|
||||
|
||||
# --- 7. Anti-cheat: no tests deleted, no packages lost -----------------------
|
||||
TEST_FUNCS=$(rg -c '^(func Test|func Benchmark)' --glob '*_test.go' 2>/dev/null | awk -F: '{s+=$2} END {print s+0}')
|
||||
[ "${TEST_FUNCS}" -ge "${BASE_TEST_FUNCS}" ] || fail "test funcs shrank (${TEST_FUNCS} < ${BASE_TEST_FUNCS})"
|
||||
TEST_FILES=$(rg --files --glob '*_test.go' 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ "${TEST_FILES}" -ge "${BASE_TEST_FILES}" ] || fail "test files deleted (${TEST_FILES} < ${BASE_TEST_FILES})"
|
||||
PASSED=$(grep -c '^ok' /tmp/ar_guard_test.txt || true)
|
||||
[ "${PASSED}" -ge "${BASE_TESTS_PASSED}" ] || fail "passing packages shrank (${PASSED} < ${BASE_TESTS_PASSED})"
|
||||
|
||||
# --- 8. License headers on Go sources (CI gate) ------------------------------
|
||||
"${ROOT}/scripts/update_go_license.sh" --check > /dev/null 2>&1 || fail "license header check failed"
|
||||
|
||||
if [ "${STATUS}" = "0" ]; then echo "CHECKS OK"; fi
|
||||
exit ${STATUS}
|
||||
echo "OK: checks passed"
|
||||
+169
-11
@@ -1,11 +1,169 @@
|
||||
# Ideas Backlog
|
||||
- extpoints 四处同构 Register 代码用泛型 helper 收敛(migration/setting/schedule/task)
|
||||
- message_gateway admin_handlers/push_handlers/push_channels 三文件互为重复 → 提取共享构建函数
|
||||
- message_gateway/repository.go 222-240 ↔ 332-350 重复查询块
|
||||
- driver_asynq_worker/plugin.go 371-390 ↔ 420-439 重复
|
||||
- admin/repository.go:507 nilerr 真 bug → 读代码确认 + 回归测试
|
||||
- inproc 驱动 contextcheck 两处 → 传 ctx
|
||||
- gosec 权限修复(test_helpers.go 0755→0750/0600, postgres.go 49)
|
||||
- nestif 四处拆函数
|
||||
- 全库性能巡查:N+1 查询、循环内 compile/alloc、锁粒度、LIKE 无 EscapeLike、缺失索引
|
||||
- 包结构优化(upload/shared、message_gateway 文件命名混乱)
|
||||
# Ideas backlog (代码质量)
|
||||
|
||||
## 已尝试并收尾(2026-08-16 会话,14 个实验,108→8)
|
||||
|
||||
- 生产代码 golangci 扩展集 13 类 linter 全量清理(modernize/perfsprint/
|
||||
errorlint/canonicalheader/usestdlibvars/intrange/wastedassign/errname/
|
||||
forcetypeassert/prealloc/gosec/recvcheck/exhaustive),剩余 8 处全部为
|
||||
有据可查的刻意保留项(telegram %v、3 处嵌套 struct omitempty、
|
||||
3 处 not-found 惯例、1 处 encoding/json 接收者混合)。
|
||||
- 测试代码质量维度(testifylint/usetesting/thelper)25→0。
|
||||
- 前端 eslint/tsc 0。
|
||||
- 修复中积累的工具经验:golangci-lint v2 `--fix` 的 import 管理不可靠,
|
||||
跑完必须 `goimports -w`;`--max-issues-per-linter=0` 才能拿到全量清单
|
||||
(默认 50 + max-same-issues=3 会掩盖重复模式);cyclop 与 exhaustive
|
||||
有张力(显式 case 计入复杂度)。
|
||||
|
||||
## 未来可深化方向(均经评估)
|
||||
|
||||
- 测试可运行性修复:`go test ./internal/...` 目前在 main 上就有失败
|
||||
(无本地 redis、frpc 进程测试 flaky)。修复这些环境问题后,可以把
|
||||
`go test` 加入 checks.sh,解锁 paralleltest/tparallel 维度
|
||||
(t.Parallel 提速 + 正确性,目前因共享状态+不可运行而放弃)。
|
||||
- frontend biome 格式漂移(76 文件):一次性 `make format` 提交,
|
||||
与质量修复分开做,不进基准。
|
||||
- fieldalignment:结构体内存布局优化,但会改变 JSON key 顺序且有
|
||||
位置字面量风险 —— 若做,需按文件人工核对,不进自动基准。
|
||||
- Go 1.26 新特性扫描:`go vet` 新分析器、golangci-lint 新 linter
|
||||
(如 recvcheck 之后的 new receivers 检查)随版本跟进。
|
||||
- 文档/示例代码(docs/、scripts/)质量:目前不在 golangci 范围(tests:false
|
||||
之外还有 scripts 目录),可用同一扩展集扫 scripts/ 下的 main.go。
|
||||
|
||||
## 会话收尾(2026-08-16,run #23 后)
|
||||
|
||||
- 已确认收敛:基准 5 维全下限、-race 全仓清零、双端测试全绿、发布构建可复现、
|
||||
config.example.yaml ↔ model.go 同步无漂移、无 flaky 测试。
|
||||
- 明确评估为不值得做的方向:paralleltest/tparallel(共享全局状态风险)、
|
||||
fieldalignment(JSON key 顺序变化)、biome 格式漂移(纯噪声)、
|
||||
frpc/frps 慢测试注入 backoff(为省 ~40s 改生产时序逻辑,不值)。
|
||||
- 未来如继续:可周期跑 `go test -race ./...` 全量(frpc/frps 慢套件);
|
||||
或前端 a11y 用 axe 做浏览器级审计(超出 eslint 静态规则)。
|
||||
|
||||
## 本会话新增(runs #39-#43)
|
||||
|
||||
已修复:
|
||||
- agent auth_cache negative 缓存无上限 → 10k 上限+过期清理(DoS 防护)
|
||||
- relay/flared 与 agent 三份重复 authenticateAccessToken → 共享 agent 版(负缓存共享,DB 压力下降)
|
||||
- websocket 三 hub:runWritePump 抽取、wsClientCore 嵌入(close/enqueue 单份)、broadcastAgent 合并
|
||||
- frps/frpc TOML 注入 → pkg/protocol/toml.go TOMLQuote 转义全部插值
|
||||
|
||||
评估后不修/暂缓:
|
||||
- cloudflare listMemberItems、config_version snapshot 证书循环的 N+1:管理端小 N 低频,
|
||||
加批量 repo API 属投机优化;若未来组员数量变大再做 ListZoneDomainsByIDs。
|
||||
- fatcontext ×3(oauth/upload/auth_source cache listener):别名赋值误报,非嵌套包装。
|
||||
- objectstore newOSSBackend/newWebDAVBackend 恒 nil error:跨后端工厂签名统一,刻意设计。
|
||||
- edge/updater assetNameForGOOSGOARCH 恒 "linux":跨平台预留参数,刻意泛化。
|
||||
- agent ResolverDirective explicitResolvers 原样插入 nginx conf:管理员配置属可信输入;
|
||||
若未来开放给低权限角色需加格式校验(IP 解析)。
|
||||
- pkg/render/openresty 管理端旋钮(ClientMaxBodySize 等)原样插值:管理员权限范围内。
|
||||
- frontend/settings/profile.tsx(858 行)超 AGENTS.md ~600 行指引:存量组件,拆分属
|
||||
纯重构无质量增益,暂缓;若后续要改该页面功能时顺手拆 components/。
|
||||
|
||||
## Run #44(全仓 -race 扫描)
|
||||
|
||||
- 发现并修复 upload/cache 监听器 DATA RACE:goroutine 读可变全局 db.Redis vs
|
||||
testhelper 清理置 nil。根因修复=启动时捕获 redisClient(oauth×2/repository×2
|
||||
同型监听器一并加固),StopUploadMetaCacheListener 补 done 等待。
|
||||
- 教训:testhelper 不能 import upload/cache(循环依赖);"捕获替代全局读"是
|
||||
无环的根因修法。
|
||||
- 全仓 -race 现为 0 竞争(internal/... + pkg/...);建议周期性重跑。
|
||||
|
||||
## LIKE 转义(本轮已修日志搜索 4 站点;同类遗留)
|
||||
|
||||
- 已修:analytics/node_access_log_filter.go、analytics/access_log_filter.go、
|
||||
logstore/postgres_store.go×2(PG/SQLite 加 ESCAPE '\',CH 用默认反斜杠转义)。
|
||||
新助手 pkg/util/like.go EscapeLike + 单测。
|
||||
- Run #47 已收尾全部 GORM 站点:upload.go keyword、user.go:73/76/188/229
|
||||
(含 OAuth uniqueUsername base 转义——外部输入含 _ 曾误报用户名冲突)、
|
||||
task_execution.go task_type 前缀。均加显式 ESCAPE '\'。
|
||||
- 刻意保留:upload.go:199 `image/%`(系统常量)、config_version.go:65(系统生成)。
|
||||
|
||||
## Run #48(后台 goroutine panic 防护,55db1c01)
|
||||
|
||||
- 全仓 20 处裸 go func() 零 recover → 新增 pkg/util/goroutine.go `Go(fn)`(recover +
|
||||
slog + debug.Stack,runtime.Caller 自动记录调用点无需手写名字),22 个站点全部收口
|
||||
(oauth/upload/system_config/auth_source 的嵌套 ctx-done watcher 也含)。
|
||||
- 教训:脚本括号深度匹配首轮会跳过嵌套内层 goroutine,需跑两轮;新 Go 文件必须先跑
|
||||
scripts/update_go_license.sh(license-check 会拦)。
|
||||
- 已过期记录:go test ./internal/... ./pkg/... 现全过(94 ok)——"main 上测试失败"
|
||||
不再成立。scripts/、docs/ 下 Go 文件用扩展 linter 扫过:0 issues。
|
||||
|
||||
## Run #50(发现型 linter 扫描,全证伪——勿重跑这些维度)
|
||||
|
||||
- errchkjson 12 处:全部为不可能失败的 json.Marshal(纯 string/int/[]string
|
||||
结构体;admin/logs/routers.go:131 与 waf/ip_group_sync.go:255 的 "unsafe type"
|
||||
是传递性保守标记,RawMessage/time.Time 内容来自必然成功的 marshal)。
|
||||
- spancheck 1 处(pkg/trace/trace.go:61):误报,helper 正常返回 span,
|
||||
唯一调用方 internal/infra/task/executor.go:242 有 defer span.End()。
|
||||
- unparam ×2(objectstore oss/webdav 恒 nil error):已在 #43 前评估为跨后端工厂签名统一。
|
||||
- 性能排查:正则全部包级编译(无函数内 MustCompile);包级 map 全为有界静态注册表;
|
||||
task AppendLog 走 DB 非内存累积;push escapeJSONString 用法正确。
|
||||
- 结论:Go 静态可发现的低垂果实已穷尽。剩余方向:frontend axe a11y 浏览器级审计、
|
||||
周期性 -race 重跑(上次 #49 干净)、运维类增长审查。
|
||||
|
||||
## Run #54(认证页 axe a11y 审计+修复,451ce525)
|
||||
|
||||
已修(复扫验证生效):
|
||||
- 布局级全局:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 18 节点/页清零)、
|
||||
header Kbd 对比度 text-foreground/70、空态/错误/加载 h3→p(heading-order 清零)。
|
||||
- 页面级:dashboard 4 个 Progress aria-label、users 分页 prev/next aria-label、
|
||||
admin/system 无内容 Tabs→aria-pressed 按钮组(aria-valid-attr-value critical 清零)。
|
||||
- / 与 /admin/system 现 axe 0 违规。
|
||||
|
||||
后续可做(页面级批量,工作量大):
|
||||
- admin 数据表格行内操作图标按钮(编辑/删除)与 Switch 开关无 aria-label —— 每张管理表逐个补;
|
||||
- muted 文本对比度(card description、radix tabs trigger、primary 按钮文字)—— shadcn 默认色在浅色主题下 axe 判 fail,改主题变量影响面大需设计确认。
|
||||
- 审计环境复用:后端 :3100 + CONFIG_PATH=/tmp/of-audit/config.yaml(sqlite)、docker redis --network host、
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100;admin 密码 reset-passwd 重置。注意 :3000 是生产实例勿动。
|
||||
|
||||
## Run #54-#55(认证页 a11y 审计,两轮 keep)
|
||||
|
||||
已修复(浏览器 axe 复扫验证):
|
||||
- 全局布局:sidebar 折叠按钮 aria-label、Sidebar role=navigation、header Kbd 对比度、
|
||||
dashboard Progress aria-label、分页 prev/next、空态/加载 h3→p、admin/system Tabs→aria-pressed。
|
||||
- 主题级根因:--primary indigo-500(#6366f1) 白字对比度仅 4.27(AA 需 4.5) → indigo-600
|
||||
oklch(51.1% 0.262 276.966) ≈6.8,一处修复全站 contrast 清零。
|
||||
- 控件名:access-analytics 刷新、events-tab Switch/编辑/删除、openflare-ops Switch/Select/
|
||||
Input(htmlFor)/Textarea、table-browser/sql-console SelectTrigger;heading-order:眉题
|
||||
h4→p(cache-manager/user-detail-sheet)、卡片题 h3→p(task-manager/file-manager)。
|
||||
- 结果:dashboard、admin/system、admin/settings、admin/logs、admin/push、admin/tasks、
|
||||
admin/database、files 共 8 页 axe 0 违规。
|
||||
|
||||
审计方法(可复用):后端 :3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite,
|
||||
api_prefix 必须显式 /api)+ docker redis --network host(本机 bridge NAT 坏)+
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100 + admin 密码经 reset-passwd 重置。
|
||||
axe 注入:eval 建 CDN script → Promise 轮询 window.axe → axe.run。
|
||||
教训:表单页异步渲染,须 wait≥5s 再扫否则漏报 label 规则;Radix SelectValue
|
||||
value='' 时 placeholder 不显示,combobox 无名需 aria-label 兜底。
|
||||
|
||||
## 剩余可做
|
||||
|
||||
- 抽查其余页面(websites/[zoneId]、origins/detail、responses 编辑器等富交互页)
|
||||
——contrast 已由主题修复覆盖,预期只剩个别控件名。
|
||||
- 周期性 go test -race ./... 全量重跑(上次干净为 run #49 后)。
|
||||
|
||||
## Run #56(富交互页抽查,keep,63e3b852)
|
||||
|
||||
- 扫描 11 页:websites/origins/proxy-routes/certificates/dns-accounts 直接 0 违规
|
||||
(indigo-600 主题修复已覆盖全站 contrast)。
|
||||
- 修复 3 处并复扫归零:
|
||||
1. cloudflare/components/sync-tasks-panel.tsx 状态筛选 SelectTrigger 加 aria-label
|
||||
(Radix SelectValue value='' 时 placeholder 不渲染,combobox 无名)。
|
||||
2. components/common/settings/access-token.tsx 安全提示 text-amber-600→amber-700
|
||||
(12px 小字对比度不足)。
|
||||
3. settings/notifications 面包屑页缺 h1 → sr-only h1。教训:h1 不能作为
|
||||
BreadcrumbList 子元素(axe list 规则报 list 语义破坏),须放 <Breadcrumb> 外;
|
||||
BreadcrumbPage 无 asChild 支持。
|
||||
- a11y 维度至此穷尽:累计 14 页 axe 全部 0 违规。
|
||||
|
||||
## Run #59(-shuffle=on 测试顺序随机化扫描,keep,b56f2763)
|
||||
|
||||
- 新维度:`go test -shuffle=on` 抓到 config_version 包测试顺序依赖——
|
||||
TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中
|
||||
Custom 用例留在进程级 RAM 配置缓存的值(GetSystemConfigByGroup 未命中时
|
||||
ram.Set 回填,TTL 跨测试存活;:memory: DB + SetDB 换库不使缓存失效)。
|
||||
- 修复:setupOriginErrorPageSnapshotDB / setupConfigVersionTestDB 换 DB 前后
|
||||
接入既有 ram.ResetForTest()。包内 shuffle×8 + 全仓 shuffle 复扫全过。
|
||||
- 教训:默认源码顺序掩盖顺序依赖;-shuffle=on 是低成本周期扫描手段。
|
||||
全仓 -race(#58 后)同样干净。其余用 SetDB 的测试包如后续 shuffle 复发,
|
||||
同法接入 ResetForTest 即可。
|
||||
|
||||
+60
-5
@@ -1,5 +1,60 @@
|
||||
{"ts":"2026-08-28T00:00:00Z","iter":0,"type":"baseline","metrics":{"lint_issues":45,"dup_issues":15,"tests_passed":44},"description":"baseline: 45 golangci issues (15 dupl), all tests pass","asi":{"note":"quick wins queue: gofumpt(4)+revive(8); then goconst/mnd; gosec; nilerr bug; contextcheck; dupl batches; nestif"}}
|
||||
{"ts":"2026-08-28","iter":1,"type":"keep","metrics":{"lint_issues":34,"dup_issues":15,"tests_passed":44},"delta":-11,"description":"goconst(9): taskCategoryUpload/taskQueueDefault consts in upload/task; reuse logDBNameSQLite in admin; mnd(1): defaultCleanupInterval in disk cache; staticcheck SA9004: split typed const group in asynq executor","asi":{"lesson":"golangci v2 defaults cap reporting at 50/3 - uncapped via issues:max-issues-per-linter/max-same-issues=0 (strict-only change); formatter war resolved: make format now = golangci-lint fmt (same gate as code-check), 203-file gofumpt normalization committed as infra"}}
|
||||
{"ts":"2026-08-28","iter":2,"type":"keep","metrics":{"lint_issues":33,"dup_issues":15,"tests_passed":44},"delta":-1,"description":"nilerr real bug: FlushTaskExecutionLog swallowed cache faults (non-miss errors) and silently dropped buffered task logs; now propagates wrapped error, ErrCacheMiss stays a no-op. 3 regression tests (fault / miss / persist+clear) with miniredis + stubDBService(in-memory sqlite)","asi":{"lesson":"FlushTaskExecutionLog callers in executor.go only log errors, so returning wrapped err is safe; tests need SetDBService injection since testhelper.SetupTestEnvironment targets infra/database global not admin dbService"}}
|
||||
{"ts":"2026-08-28","iter":3,"type":"keep","metrics":{"lint_issues":26,"dup_issues":15,"tests_passed":44},"delta":-7,"description":"revive cleanup: symmetric accessors ctx->_, unused params->_, doc comments for SetDBServiceForTest and StorageDriver const block","asi":{"lesson":"callers pass targetCfg positionally so _ at def site is safe; accessor ctx removal considered but _ keeps 24 call sites stable"}}
|
||||
{"ts":"2026-08-28","iter":4,"type":"keep","metrics":{"lint_issues":24,"dup_issues":15,"tests_passed":44},"delta":-2,"description":"contextcheck: thread app-lifetime ctx through inproc drivers. inproc_cron: Plugin.Start(ctx)->scheduler.Start(ctx)->registerJob(ctx,def); cron closures now Dispatch/log/invoke with ctx (child WithTimeout). inproc_worker: InprocQueue.baseCtx captured at Start(ctx); executeTask uses WithTimeout(q.baseCtx). app.go passes signal/base ctx (only cancelled on shutdown) so this adds graceful-shutdown propagation","asi":{"lesson":"Start ctx is lifetime-scoped (signal.NotifyContext or GoContext), NOT startup-scoped - safe for task dispatch; nil-guard baseCtx in queue.Start allows tests constructing queue directly"}}
|
||||
{"type":"config","name":"前后端代码质量优化(符合最佳实践)","metricName":"total_issues","metricUnit":"","bestDirection":"lower"}
|
||||
{"run":1,"commit":"305d609","metric":108,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":2,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":107,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":36},"status":"checks_failed","description":"基线:总问题 108(golangci 107 + eslint 1)。checks 失败的唯一原因:repo 自带 golangci gate 有 2 个既有 gosec G115 问题(预期内,首次修复后即绿)。","timestamp":1786871594292,"segment":0,"confidence":null,"asi":{"hypothesis":"baseline","next_action_hint":"修复 internal/apps/edge/observability/linux.go 的 2 个 G115 gosec 问题后 checks.sh 才能通过;之后每次迭代即可正常 keep/discard"}}
|
||||
{"run":2,"commit":"f1f6bb8","metric":106,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":105,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"修复 internal/apps/edge/observability/linux.go 的 2 个 gosec G115 整数溢出转换:helper 改为接收 int64 b,用 gosec 认可的饱和乘法模式(uint64 域乘积 + 上界比较),去掉原 //nolint:gosec,语义不变(Bsize 恒为正)。repo 自带 gate 首次全绿。","timestamp":1786872064145,"segment":0,"confidence":null,"asi":{"hypothesis":"修复 gosec G115:multiplyUint64ToInt64 改为 accept int64 b 并采用 gosec 认可的饱和乘法模式","insight":"gosec G115 不接受分支上界证明(a > MaxInt64/b),但接受先算 uint64 乘积再 if v > MaxInt64 饱和的模式,无需 nolint","next_action_hint":"下一步批量清理 modernize(37)/perfsprint(18) 等自动可修复类别,用 golangci-lint --fix 后人工核对 diff"}}
|
||||
{"run":3,"commit":"b76f707","metric":74,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"modernize 37→5(-32):interface{}→any、内置 max/min、slices/maps 辅助、strings.Cut/SplitSeq、strings.Builder(修复 mail.go O(n²) 拼接)。逐 hunk 核对语义等价;omitzero 冲突修复被自动跳过(wire 格式不变);手动清 4 处遗留 sort import + 2 处 QF1012。","timestamp":1786872502383,"segment":0,"confidence":17,"asi":{"hypothesis":"批量应用 modernize 自动修复(interface{}→any、max/min、slices.Sort/Contains、strings.Cut/SplitSeq、maps.Copy、strings.Builder)","insight":"golangci-lint --fix 会把 omitempty→omitzero 的冲突修复跳过(2个文件保留原 tag,wire 格式不变,好);但 fixer 会遗留未使用的 sort import,需手动清 4 处;mail.go 的 Builder 迁移附带暴露 2 个 QF1012,顺手用 fmt.Fprintf 修复。全部修复语义等价,已逐项核对 diff","next_action_hint":"剩余 modernize=5 应为 omitzero 冲突文件;下轮先处理 perfsprint(18):先看 --fix 是否安全再决定"}}
|
||||
{"run":4,"commit":"699e95f","metric":56,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"perfsprint 18→0:strconv.Itoa/FormatInt/FormatUint/FormatBool 替代 fmt.Sprintf、无动词 fmt.Errorf→errors.New、纯字符串拼接。全部语义等价(已核对 diff)。修正 fixer 遗留的 import 问题(引入 goimports 统一整理)。","timestamp":1786872884713,"segment":0,"confidence":3.0588235294117645,"asi":{"hypothesis":"perfsprint --fix:%d→strconv.Itoa/FormatInt、%t→FormatBool、%s+const→拼接、无动词 Errorf→errors.New","insight":"重要:golangci-lint v2 fixer 的 import 管理不可靠(删除/添加 import 会出错,53 个文件中 5 处报 undefined)+ 遗留未用 import。已安装 goimports(repo make format 本来就需要它),对改动文件统一 goimports -w 修复。后续只要用 --fix 就要记得跑 goimports -w","next_action_hint":"剩余大头:errorlint(12)、canonicalheader(8)(usestdlibvars 同类)、recvcheck(7)、wastedassign(7)。errorlint 需手工逐处判断;先做 canonicalheader+usestdlibvars(自动可修复但要核对)"}}
|
||||
{"run":5,"commit":"d0414b4","metric":45,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。","timestamp":1786873098921,"segment":0,"confidence":2.1724137931034484,"asi":{"hypothesis":"canonicalheader+usestdlibvars --fix:Header key 统一规范大小写、GET/OPTIONS 等方法常量","insight":"GitHub header 修正前后的 wire 格式完全一致(Go 在 Set 时本来就会规范化),纯代码层面修正,零行为风险;下次遇到同类 100% 安全","next_action_hint":"剩余:errorlint(12) 需逐处人工判断(其中 3 处 err != context.Canceled、2 处 %v wrap、若干 ==/类型断言);recvcheck(7) 是模型接收者一致性;wastedassign(7) 删 TODO 赋值;intrange(3)/modernize(5)/nilnil(3)/prealloc(3)/forcetypeassert(3)/errname(1)/eslint(1)"}}
|
||||
{"run":6,"commit":"ce28f63","metric":38,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"wastedassign 7→0:删除 7 处死初始化(snapshot.go 三连、push 三件套 content、format.go numStr),改 var 声明,零行为变化。","timestamp":1786873485497,"segment":0,"confidence":2.978723404255319,"asi":{"hypothesis":"wastedassign 7→0:删除 7 处死初始化(x := \"\" 后所有分支都赋值)改为 var 声明","insight":"replace 工具会归一化 replacement_text 的前导空白;对需要缩进的编辑直接用 sed/gofmt -w 处理更稳","next_action_hint":"剩余:errorlint(12)、recvcheck(7)、modernize(5)、intrange(3)、nilnil(3)、prealloc(3)、forcetypeassert(3)、errname(1)、eslint(1)"}}
|
||||
{"run":7,"commit":"288b74d","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"intrange 3→0 + modernize 5→3:for i:=0;i<len/N;i++ → range len/N(8 处);time.Time 字段 omitempty→omitzero(wire 输出一致);SplitSeq;min() 简化。刻意保留 lark.go omitzero(会改变 wire 行为)。","timestamp":1786873629461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"intrange(3) + modernize 剩余(2 个 time.Time omitempty→omitzero + SplitSeq + min)","insight":"lark.go larkTextContent omitempty→omitzero 会改变 wire(普通 struct 无 IsZero,当前恒序列化,改后零值省略)—— 判定为行为变化,故意保留;time.Time 字段 omitempty/omitzero 输出一致,可安全替换","next_action_hint":"剩余:errorlint(12) 大头(3 处 != context.Canceled 需确认 runner 是否 wrap;%v→%w 2 处;若干 ==err / 类型断言);recvcheck(7);forcetypeassert(3);nilnil(3);prealloc(3);errname(1);eslint(1)"}}
|
||||
{"run":8,"commit":"86fad02","metric":22,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":1,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":21,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":46},"status":"keep","description":"errorlint 12→1:3 处 cmd 入口 err!=context.Canceled→errors.Is(防御性,当前 runner 不 wrap 语义不变);2 处 strconv.NumError 断言、1 处 viper 断言、2 处 ==io.EOF、2 处 ==redis.Nil、1 处 ==gorm.ErrRecordNotFound→errors.As/Is;8 处 %v→%w 保留错误链。刻意保留 telegram.go 单处 %v(原始错误仅作上下文文本,wrap 会改变 errors.Is 匹配语义)。","timestamp":1786873923775,"segment":0,"confidence":4.195121951219512,"asi":{"hypothesis":"errorlint 12→1:errors.Is/As 替代 ==/类型断言(防御 wrap),%v→%w 保留错误链","insight":"errorlint 结果在并行分析时一度不稳定(可能文件缓存竞争),多跑一次确认;telegram.go 的 %v 是刻意保留原始 HTML 错误为文本(只 wrap fallbackErr),判定为合理例外,不计为负债。错误链保留(%w)对多错误组合消息(manager.go、restart_unix.go、service.go)是净收益,调用方无 Is 匹配这些次要错误","next_action_hint":"剩余:recvcheck(7)、forcetypeassert(3)、nilnil(3)、prealloc(3)、modernize(3=lark omitzero 刻意保留)、errname(1)、eslint(1)"}}
|
||||
{"run":9,"commit":"4ecec2c","metric":15,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":14,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":43},"status":"keep","description":"forcetypeassert 6→0(缓存 list 断言、relay/flared 中间件契约断言、图片压缩 flight 断言,全部带检查+安全失败路径);errname 1→0;prealloc 2 处(另 1 处与 repo mnd 冲突,用命名常量解决)。nilnil 保留(not-found/可选结果惯例,含接口契约注释)。","timestamp":1786874283774,"segment":0,"confidence":4.043478260869565,"asi":{"hypothesis":"forcetypeassert(6处) → 带检查断言(middleware 契约破坏时 Abort 401/返回错误);errname runtimeInitErr→errRuntimeInit;prealloc 2 处(uptimekuma、postgres replicas)","insight":"prealloc 与 repo mnd 门禁冲突(magic number 3):用命名常量 baseTracingOptionCount 同时满足两者;nilnil 5 处判定为合法 not-found/可选结果惯例(含接口注释契约 + 测试断言),全部保留;用 --max-issues-per-linter=0 拿全量清单避免被默认 50 截断误导","next_action_hint":"剩余:recvcheck(7) 接收者一致性(需逐模型判断)、eslint(1) exhaustive-deps、modernize(3=lark omitzero 刻意保留+2 处待查)、nilnil(3 刻意保留)、errorlint(1 刻意保留)"}}
|
||||
{"run":10,"commit":"73d8173","metric":9,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"recvcheck 7→1:6 个 GORM 模型 TableName 改为指针接收者(GORM 源码确认 reflect.New 判定 Tabler,兼容;模型单测通过)。MillisecondDuration 刻意保留(encoding/json 要求 Marshal 值/Unmarshal 指针的混合)。","timestamp":1786874445733,"segment":0,"confidence":4.304347826086956,"asi":{"hypothesis":"recvcheck 7→1:GORM 模型 TableName 值接收者→指针接收者,与其它方法一致","insight":"GORM schema.Parse 用 reflect.New(modelType) 判定 Tabler,指针接收者 TableName 完全兼容(已读 gorm 源码确认 + 模型单测通过);仓库中 (Model{}).TableName() 字面量调用都在未改的类型上,无破坏。MillisecondDuration 保留:MarshalJSON 值接收者是 json 对不可寻址值的行为保障,UnmarshalJSON 必须指针 —— 混合是 encoding/json 硬性要求","next_action_hint":"剩余:modernize(3,含 lark omitzero 刻意保留 + 2 处待查)、nilnil(3 刻意保留)、eslint(1 exhaustive-deps)、errorlint(1 刻意保留)。下一步查 modernize 剩余 2 处并修 eslint 的 hook 依赖"}}
|
||||
{"run":11,"commit":"111d290","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":38},"status":"keep","description":"eslint 1→0:pages-source-card useEffect 补 t 依赖(next-intl 稳定引用)。modernize 补 1 处 time.Time omitzero。剩余 8 全部为刻意保留项。","timestamp":1786874578893,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"eslint 1→0:useEffect 依赖数组补 t(next-intl useTranslations 返回稳定引用,安全);modernize 补 1 处 time.Time omitempty→omitzero(输出一致)","insight":"modernize 剩余 3 处全部是嵌套 struct omitempty(client.go Release/Asset、lark.go Content)→ omitzero 会改变 wire,全部刻意保留。至此所有可安全修复的类别清零,剩余 8 个全部是有据可查的刻意保留项","next_action_hint":"剩余 8 全部刻意保留(errorlint 1 telegram、modernize 3 嵌套struct、nilnil 3 not-found、recvcheck 1 json)。下一轮做深化方向:测试代码质量(tests:false 之外)、或 golangci 附加 linter(gocritic 更多检查)作为新基准段"}}
|
||||
{"run":12,"commit":"e5f6b0a","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":20,"golint_test_thelper":3,"golint_test_usetesting":2,"golint_test_total":25,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"基准扩展(文档化):新增测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2),生产代码 8 处刻意保留不变。新基线 total=33。","timestamp":1786874744438,"segment":0,"confidence":4.878048780487805,"asi":{"hypothesis":"扩展基准到测试代码质量维度(testifylint 20 + thelper 3 + usetesting 2 = 25)","insight":"刻意排除 paralleltest/tparallel(共享 DB/redis 状态 + 本环境无法跑测试,t.Parallel 有风险)—— 这是范围扩展(抬高门槛),不是 gaming;基准定义已写入 prompt.md","next_action_hint":"修 25 处测试问题:float-compare 3(InDelta)、require-error 3、encoded-compare 1(JSONEq)、empty 3、contains 1、error-is-as 3、len 3、go-require-in-handler 2、t.Helper 3、os.MkdirTemp→t.TempDir 2"}}
|
||||
{"run":13,"commit":"63a24da","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"测试代码质量 25→0:assert↔require 一致性(fail-fast)、float 精确比较→InDelta、Equal(\"\",x)→Empty、Equal(len)→Len、errors.Is/As→ErrorIs/ErrorAs、JSON 字符串→JSONEq、handler goroutine 内 require→assert(真健壮性修复)、t.Helper()、os.MkdirTemp→t.TempDir()(符合 repo AGENTS 约束)。","timestamp":1786875177918,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"修完测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2)","insight":"批量修复过程揭示 testifylint 默认 max-same-issues=3 会掩盖重复模式(len(entries) 出现 4+ 次、float64(3) 4 次),需 --max-issues-per-linter=0 反复收敛;全部修复语义中性(assert↔require 仅 fail-fast 差异,InDelta/JSONEq/Empty/Len/ErrorIs 等价断言,t.Helper/t.TempDir 纯改善)。go-require 类(handler 内 require→assert)是真正的健壮性修复","next_action_hint":"测试维度清零。生产代码剩余 8 全部刻意保留。可选深化:gocritic 更多子检查/staticcheck 更多(SA 系列)扫描、或 biome check 格式一次性提交、或前端 a11y(eslint jsx-a11y 已含于 next core-web-vitals 默认关闭项)"}}
|
||||
{"run":14,"commit":"65c02ef","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36},"status":"keep","description":"基准扩展 exhaustive(文档化)+ 12→0:枚举 switch 补显式 case(全部与现有 default 行为等价,fail-explicit 防未来枚举静默落入 default);source_tasks.go 为控制复杂度合并两个等价校验条件。","timestamp":1786875548060,"segment":0,"confidence":4.25531914893617,"asi":{"hypothesis":"基准扩展 exhaustive(12 处枚举 switch 显式化)+ 全量修复","insight":"12 处全部是 default 已正确处理、缺显式 case 的类型;补显式 case 仅为 fail-explicit(未来枚举新增不会静默落入 default)。source_tasks 补 case 后 Execute 复杂度 20→21 触发 cyclop,合并两个 ActionInvalid 条件(逻辑等价)降回 19。cyclop 与 exhaustive 的张力:显式 case 也计入复杂度","next_action_hint":"剩余 8 全为刻意保留。可再深化:sloglint 全量、govet 附加分析器、或前端 jsx-a11y/next 规则已有覆盖。也可将剩余 8 处文档化后收尾总结"}}
|
||||
{"run":15,"commit":"d7b8f44","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"修复 geoip/runtime.go 真死代码:ensureServerMMDB 的 os.Stat 错误被 if-init 遮蔽,`err != nil && !os.IsNotExist(err)` 恒为 false(外层 err 恒 nil),防御检查从未生效;改为显式捕获 statErr,stat 非 not-exist 错误现在正确返回。基准新增第 4 维度 govet nilness+unusedwrite(文档化扩展),当前 0。","timestamp":1786875949461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"govet nilness 真实死代码 bug:ensureServerMMDB 的 stat 错误被 if-init 遮蔽,!os.IsNotExist(err) 恒为死条件(外层 err 恒 nil)","insight":"修复:显式捕获 statErr,使防御检查生效(stat 权限错误现在立即返回,不再静默吞掉后走 WriteFile 失败)。顺带基准扩展第 4 维度 govet nilness+unusedwrite(文档化,survey 过 fatcontext/containedctx/unparam/gocritic+29 检查:unparam 有 6+ 处真实死结果但需签名改动,留待下轮)","next_action_hint":"下轮候选:unparam(6+ 处 always-nil/never-used 结果,含 getSQLiteOverview/getPostgresOverview/getStatus 等,需改签名+调用方,churn 中等但都是真实死代码);或 fatcontext/containedctx(3+3 处,需逐处判断是否真反模式)"}}
|
||||
{"run":16,"commit":"c85373f","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":43},"status":"keep","description":"unparam 死代码清理 12→2(保留 2 处 objectstore 构造函数统一签名):移除 10 处恒 nil error / 从未使用的结果(getPoWConfigForRoute 的恒 nil *PoWConfig、getSQLiteOverview/getPostgresOverview/getStatus/loadKumaConfig/filterExpectedRoutes 的恒 nil error、rawJSONString/parsePositiveInt 的弃用 bool、buildProxyRoute 的弃用 []ZoneDomain、getLocked 的恒 nil error),同步简化 12+ 处调用方与死错误检查。9 个受影响包测试通过。metric 持平 8(改进在基准之外)。","timestamp":1786876191447,"segment":0,"confidence":5.128205128205129,"asi":{"hypothesis":"unparam 死代码清理:10 处 always-nil error / never-used 结果从签名移除","insight":"移除后调用方同步简化(db_manage 的 err 检查、option routers 的 AbortBadRequestOnError 成为死代码一并删)。getPoWConfigForRoute 的 *PoWConfig 结果恒 nil 且从未被用 —— 真死代码。保留 2 处 objectstore 构造函数 (X, error):factory switch 统一签名(newS3Backend/newLocalBackend 等可能真实报错),unparam 在此为接口一致性误报。全部 9 个受影响包测试通过。metric 持平 8(改进在基准之外,诚实记录)","next_action_hint":"下一候选:fatcontext(3 处嵌套 context 闭包,多为 slog/otel ctx 传递,需逐处判断是否真反模式) 或 containedctx(3 处 struct 含 ctx 字段,含 webdav/uptimekuma client —— 重构风险中等);或收尾把 unparam 加入基准(2 处已知保留)"}}
|
||||
{"run":17,"commit":"a16be01","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":40},"status":"keep","description":"修复 frpc 进程生命周期真 bug(agent 生产代码):exec.CommandContext 默认只杀直接子进程,被杀 shell 的孤儿 sleep 继续持有 stderr 管道,cmd.Wait() 阻塞到其自然退出(Stop/重启可挂起秒级)。改 Setpgid 进程组 + Kill(-pid) 整组击杀。连带修复两个测试 bug(Manager 拥有 Cmd 的并发 Wait 竞态 → Signal(0) 探测;ssl_renew 用 miniredis 替代 init() 创建的真实 redis 客户端)。go test ./internal/... ./pkg/... 全绿,checks.sh 升级为真实测试门禁。","timestamp":1786877266517,"segment":0,"confidence":7.142857142857143,"asi":{"hypothesis":"frpc 进程生命周期真 bug:exec.CommandContext 只杀直接子进程,孤儿孙进程持有 stderr 管道导致 cmd.Wait 阻塞到其自然退出(实测脚本 sleep 5 时 Stop 挂起 5s)","insight":"修复:Setpgid 独立进程组 + cmd.Cancel 覆盖为 Kill(-pid,SIGKILL) 整组击杀(经隔离复现 + 临时插桩定位,4 次假设检验收敛)。连带修复两个测试 bug:TestStopCancelsRunningProcesses 对 Manager 拥有的 Cmd 并发 Wait(与 os/exec ctxResult 通道竞争永久挂起)改为 Signal(0) 探测;ssl_renew 测试改用 miniredis(task 包 init() 创建真实 redis 客户端,违反 repo 无 init 装配约束)。成果:go test ./internal/... ./pkg/... 从 3 个失败→全绿(81+13 包),checks.sh 升级为真实测试门禁。metric 持平 8(改进在基准之外,但价值最高的一轮)","next_action_hint":"测试全绿后可解锁:paralleltest/tparallel 维度(t.Parallel 提速)——需先评估共享状态(miniredis/sqlite 每测试独立,风险低);或探索 relay/frps 同构代码是否有同样的 group-kill 问题(frps/manager 结构相同,值得检查)"}}
|
||||
{"run":18,"commit":"f5c9da0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。","timestamp":1786878501539,"segment":0,"confidence":9.523809523809524,"asi":{"hypothesis":"前端测试可运行性:next-intl 迁移后 44/116 测试失败(缺 NextIntlClientProvider + 3 处真实断言问题)","insight":"修复三类:(1) 10 个测试文件的 render 助手缺 NextIntlClientProvider(createElement 与 JSX 混用踩 React19 类型坑,.ts 文件不能写 JSX → 重命名为 .tsx);(2) 真实 i18n bug:githubUrlInvalid 消息的 {owner}/{repo} 被 ICU 当占位符,t() 无参调用渲染成 key —— 需 '{' 单引号转义('{}' 内层转义不够,必须整体引号包裹 '{owner}'),4 个消息文件(zh/en + fragment 源)同步修复,check:i18n 通过;(3) 2 处测试期望过期(唯一访问者→查询窗口独立访客、检查间隔→检查间隔(分钟),以消息文件为准)。成果:116/116 vitest + tsc/eslint 全绿,checks.sh 增加前端测试门禁","next_action_hint":"前端测试全绿后可把 vitest 失败数纳入基准(当前不在基准内);或检查 app/(main) 目录下 3 个自带 .test.tsx(waf editor 系列)是否也符合新约定"}}
|
||||
{"run":19,"commit":"c455be3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":62},"status":"keep","description":"基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。","timestamp":1786878719509,"segment":0,"confidence":14.285714285714286,"asi":{"hypothesis":"基准扩展第 5 维度:前端 vitest 失败数(全绿后纳入防回归,文档化范围扩展非作弊)","insight":"measure_s 从 39s 升到 62s(vitest ~20s + eslint 冷启动),可接受。5 个维度全部在其下限:生产 8(全刻意保留)+ 测试 0 + govet 0 + eslint/tsc 0 + vitest 0","next_action_hint":"基准已 5 维全下限。后续可深化:paralleltest(现在测试可跑,但共享全局状态风险仍在,低优先);或 frontend biome 格式一次性提交(不进基准);或前端组件更深规则(jsx-a11y 已在 next core-web-vitals 覆盖)。也可认为会话到达稳定收尾点,更新 prompt/ideas 后总结"}}
|
||||
{"run":20,"commit":"4962bf9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":86},"status":"keep","description":"两处真实质量修复:(1) 过期 swagger 文档重新生成(status_2xx/4xx/5xx_count 字段随 a4dd5ca9 加入后未同步 docs,违反 repo 约定,swag init 后差异仅真实新增字段);(2) generate-themes.js 输出补尾换行,themes.json 构建可复现(此前每次 build 弄脏工作树)。验证 next build 成功、musttag/tagalign 调查无真实问题。","timestamp":1786879144888,"segment":0,"confidence":25,"asi":{"hypothesis":"验证生产构建 + 修两处真实质量问题:swagger 文档过期(status_2xx/4xx/5xx_count 新增字段未重新生成)与 themes.json 构建不可复现(generate-themes.js 缺尾换行,每次 build 弄脏工作树)","insight":"next build 成功(无构建问题);musttag 3 处与 tagalign 均判定为非问题(持久化 round-trip 自洽/调试日志/纯格式)。swagger 差异仅 27 行且全部真实(a4dd5ca9 状态码拆分字段)。generate-themes.js 补 '\\n' 后 themes.json 再生与提交版完全一致,构建可复现。metric 持平 8(改进在基准之外)","next_action_hint":"会话已 5 维全下限 + 构建可复现 + 双端测试全绿。收尾候选:更新 prompt/ideas 记录本轮成果后总结;或继续验证 swag 生成的 docs.go 在 CI 中的可复现性"}}
|
||||
{"run":21,"commit":"e1b439d","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":74},"status":"keep","description":"全仓 go test -race 扫描(93 包)→ 全绿。修复 6 类数据竞争:frpc/frps 测试的锁外读与并发 Wait;oauth/repository 4 个 Pub/Sub 监听器 goroutine 读可变包变量(局部捕获 + done 通道等待);oauth 测试换 db.Redis 前停监听器;【真实生产 bug】tls 响应快照与异步续签 goroutine 并发写 cert 竞争(先快照再起 goroutine);upload/cache 监听器 goroutine 内读 db.Redis(调用方捕获)。","timestamp":1786881379399,"segment":0,"confidence":100,"asi":{"hypothesis":"全仓 -race 扫描:发现并修复 6 类数据竞争,含 1 个真实生产竞争","insight":"修复清单:(1) frpc 测试助手锁外读 proc.Status;(2) frps 测试对 Manager 拥有的 Cmd 并发 Wait(与 frpc 同类);(3) oauth/repository 4 个 Pub/Sub 监听器 goroutine 内读可变包变量 userListenerCtx 等 → 局部捕获 + stop 增加 done 通道等待;(4) oauth 测试 setupTestRouter 换 db.Redis 前先停各层监听器;(5)【真实生产 bug】tls logics 的 sanitizeCertificateForResponse 整体拷贝 cert 与异步续签 goroutine 字段写入并发 → 先快照再起 goroutine;(6) upload/cache 监听器 goroutine 内读 db.Redis → 调用方捕获。成果:93 包 go test -race 全绿。基准 5 维全下限不变(改进在基准外,但 tls 竞争是生产级真实问题)","next_action_hint":"可考虑把 -race 纳入周期性验证(不进每次 checks,全仓 ~3 分钟);或在 prompt/ideas 记录本轮成果"}}
|
||||
{"run":22,"commit":"ab70633","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":68},"status":"keep","description":"checks.sh 新增并发密集包 -race 门禁(8 个快速包,全仓 -race 清零后纳入防回归;frpc/frps 慢套件留作周期全量验证)。核查 7 处 t.Skip 均为合法环境门控。","timestamp":1786881700477,"segment":0,"confidence":200,"asi":{"hypothesis":"把 -race 门禁纳入 checks.sh(并发密集包子集,全仓 -race 清零后防回归)","insight":"选 8 个快速并发包(oauth/tls/uptimekuma/upload-cache/repository/disk-cache/logger/batchwriter,约 20s),排除 frpc/frps 慢套件(15s backoff + 25s supervisor,另做周期全量验证)。checks 总耗时 ~60s 可接受。顺带核查 7 处 t.Skip 均为合法环境门控(PG/CH 集成、symlink 平台差异),无隐藏损坏。metric 持平 8","next_action_hint":"会话已达全面收敛:5 维基准全下限 + 全仓 -race 清零 + 双端测试全绿 + 构建可复现 + -race 门禁入 checks。可收尾:更新 prompt/ideas 后总结"}}
|
||||
{"run":23,"commit":"aa4fadd","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81},"status":"keep","description":"补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。","timestamp":1786882164158,"segment":0,"confidence":null,"asi":{"hypothesis":"跑 repo 自带门禁 make license-check:发现 131 个 .go 文件缺 SPDX 头(早于 license 约定,含 2 个生产文件与多份本会话早期改过的文件)","insight":"用 repo 自带 scripts/update_go_license.sh(make license)自动补头(391 行纯注释插入),license-check 转绿;go mod tidy -diff 干净。gofmt/build/全测试/-race 均通过。metric 持平 8","next_action_hint":"会话全维度收敛。收尾:把 license-check 加入 checks.sh 防再犯;更新 prompt/ideas 最终状态"}}
|
||||
{"run":24,"commit":"4d78bc1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":83},"status":"keep","description":"收敛验证轮:3× 时序敏感包测试无 flake;config.example.yaml ↔ model.go 同步确认无漂移(scheduler 空结构、replicas 注释化、queues[].name 嵌套均为误报);make build-embedded 发布路径构建成功且工作树干净(bin/、frontend/out 已 gitignore)。更新 prompt/ideas 记录会话终态。metric 持平 8。","timestamp":1786882694298,"segment":0,"confidence":null,"asi":{"hypothesis":"会话收敛验证:跑 3× 时序敏感包(无 flake)、config.example.yaml↔model.go 同步(3 个疑似缺失均为误报:scheduler 空结构/replicas 注释化/queues[].name 嵌套)、make build-embedded 发布路径(成功且树干净)","insight":"全部为确认性结果,无新问题。诚实记录:本轮零代码改动(上一轮已修 license),metric 持平 8。会话正式收敛:基准 5 维全下限 + 全仓 -race 清零 + 双端测试全绿 + 发布可复现 + config/文档同步。prompt/ideas 已更新终态","next_action_hint":"会话已收敛。若继续:周期全量 -race、前端 axe 浏览器级 a11y 审计(ideas.md 已记录)"}}
|
||||
{"run":25,"commit":"7f8e257","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":118,"measure_s":71},"status":"keep","description":"前端真实 a11y 审计:新增 axe-core(devDep)+ tests/a11y.test.tsx,对登录页与注册页渲染完整表单后运行 axe 结构性规则(label/button-name/heading-order/landmark/aria),两页均零违规。摸清并处理了渲染依赖(UserProvider 会话检查、publicConfigQuery 门控、configBool 字符串语义)。vitest 116→118 全绿。","timestamp":1786884595141,"segment":0,"confidence":null,"asi":{"hypothesis":"前端真实 a11y 审计:axe-core(jsdom 结构性规则)覆盖登录/注册页,超出 eslint 静态 jsx-a11y 的动态可访问性验证","insight":"新增 tests/a11y.test.tsx(2 测试)+ axe-core devDependency。调试中摸清登录/注册页渲染依赖链(UserProvider 挂载跳查 getUserInfo、LoginForm/RegisterForm 门控 publicConfigQuery、configBool 期望字符串 'true' 而非布尔 —— mock 需给字符串)。两页均零 axe 违规(color-contrast 因 jsdom 无布局引擎禁用,文档化)。vitest 116→118,checks 全绿。metric 持平 8","next_action_hint":"可扩展 axe 到更多页面(如登录 OTP 态、设置页),或收尾。axe 依赖仅 devDependency,不进基准计数(vitest_failed 已含新测试)"}}
|
||||
{"run":26,"commit":"7d03154","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":120,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到登录 OTP 验证表单(input-otp 分段输入,FieldLabel htmlFor 正确关联,零违规)与人机验证小部件手动模式(零违规)。环境修复:tests/setup.ts 加 ResizeObserver mock(input-otp 依赖,jsdom 未内置)。vitest 118→120 全绿。","timestamp":1786885103995,"segment":0,"confidence":null,"asi":{"hypothesis":"把 axe a11y 审计扩展到登录/注册外的关键表单状态:登录 OTP 验证(input-otp 分段输入是经典 a11y 雷区)与人机验证小部件(CapWidget 手动模式)","insight":"新增 2 个 axe 测试(vitest 118→120)。OTPForm 验证通过 —— FieldLabel htmlFor='otp-verification' 与 InputOTP id 正确关联,分段输入零违规;CapWidget 手动模式零违规。环境修复:input-otp 依赖 ResizeObserver,jsdom 未内置 → tests/setup.ts 加 ResizeObserver mock(全局,其它测试无害)。渲染上下文修正:裸组件需 <main> landmark 包裹(region 规则,真实页面由 AuthShell 提供)。scripts/ Go 代码用扩展 linter 集扫描为 0 问题(死路,未纳入基准)。metric 持平 8","next_action_hint":"a11y 覆盖已达:登录/注册页/OTP/CapWidget 全零违规。剩余页面(设置页、admin)需较重 mock。会话可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":27,"commit":"6c128e0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到最复杂认证路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单),mock getCapToken 避免 jsdom 无 Worker 环境限制。零违规。vitest 120→121 全绿。","timestamp":1786885536618,"segment":0,"confidence":null,"asi":{"hypothesis":"axe 审计覆盖最复杂的认证渲染路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单)","insight":"新增第 5 个 axe 测试(vitest 120→121):mock @/lib/cap-solver 的 getCapToken 直接 resolve token(避免 jsdom 无 Web Worker),config 开 cap_login_enabled/cap_auto_solve,注册页渲染出 CAPTCHA 已通过态 + 表单全字段 → 零违规。vi.mock('@/lib/cap-solver') 对其它测试无害(仅 capEnabled 时渲染 CapWidget)。metric 持平 8","next_action_hint":"axe 覆盖已达 5 个认证表单态(登录/注册/OTP/验证小部件手动/注册+验证)。剩余:设置页与 admin 页需较重 mock。可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":28,"commit":"40eee77","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":0},"status":"keep","description":"前端显式 any 类型清理 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable 注释删除。tsc/eslint/vitest 121 全绿。","timestamp":1786886086713,"segment":0,"confidence":null,"asi":{"hypothesis":"前端显式 any 类型清理:全仓 grep 仅 2 处 any —— Slot children?: any 与 useControlledState 的 Rest extends any[],均为真实类型缺陷","insight":"全前端 any 计数 2→0。slot.tsx:children?: any → React.ReactNode | MotionValue<string> | MotionValue<number>(motion HTMLMotionProps 的真实 children 类型);顺带修复潜在崩溃 —— 原代码在 isValidElement 检查前就访问 children.type,children 缺失时 TypeError,改为 isValidChild/childrenType 先计算(hooks 无条件,rules-of-hooks 合规),无效 children 返回 null。use-controlled-state.tsx:Rest extends any[] → unknown[]。两处 eslint-disable no-explicit-any 注释随之删除(无抑制注释)。tsc/eslint/vitest 121/checks.sh 全绿。benchmark 无关(metric 持平 8)。注意:run #28 的 run_experiment 被用户中断(aborted),但代码修复已通过全部门禁验证","next_action_hint":"用户要求合并到 main 并推送"}}
|
||||
{"run":29,"commit":"511bed8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":63,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":124},"status":"keep","description":"修复 2 个新增 unconvert 问题(linux.go 中 int64(stat.Bsize) 恒等转换,Statfs_t.Bsize 在 Linux 上本就是 int64),删除多余转换零行为变化;total 10→8 回到 5 维全下限。","timestamp":1786894372432,"segment":0,"confidence":null,"asi":{"category":"unconvert","hypothesis":"会话恢复后 measure 显示 total=10,出现 2 个新的 unconvert 问题(internal/apps/edge/observability/linux.go:261-262 的 int64(stat.Bsize) 恒等转换,Linux Statfs_t.Bsize 本就是 int64)。删除多余转换,零行为变化","finding":"unconvert 是 repo 自带配置启用的 linter,此前 baseline 无此问题,最近用户提交/Go 版本变化后新增;修复后 5 维回到全下限 8","next_action_hint":"会话恢复点确认:total=8(5 维全下限,8 项均为有据可查的刻意保留)。下一轮候选:静态检查新维度(staticcheck SA 系列在 repo 配置中已启用且为 0)、或把 docs/ 下 vitepress 站点的构建纳入 measure 防回归(docs build 不属质量计数,不进基准)"}}
|
||||
{"run":30,"commit":"d49c7e1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":183,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"checks_failed","description":"Agent 发现 Token 比较改为 SHA-256 后恒定时间 Compare,堵住未授权节点注册口的计时侧信道。checks 在 -race 阶段超时(包本身已单独跑绿)。","timestamp":1787667218065,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","rollback_reason":"checks.sh 在 go test -race 阶段 300s 超时(包单独跑全绿,预算不够)","next_action_hint":"同一修复用 checks_timeout_seconds=600 重跑"}}
|
||||
{"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}}
|
||||
{"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}}
|
||||
{"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}}
|
||||
{"run":37,"commit":"0290c93","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。","timestamp":1787670327304,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /oauth/login 与 /oauth/{source}/authorize 每次请求都往 Redis 写 10 分钟 state,无上限","finding":"按 sessionHash 计数,10 分钟内最多 20 个;超出返回业务错误。mock Redis 补 Incr/Expire。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 洪水"}}
|
||||
{"run":38,"commit":"c0a82f8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":85,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开密码登录口按 IP 限制 10 分钟内最多 20 次失败,堵住未授权爆破。metric 持平 8。","timestamp":1787670665553,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /user/login 失败无 IP 限流,未授权方可无限爆破","finding":"按 ClientIP 计数,10 分钟 20 次失败后拒绝;成功清零。管理员已登录不在范围内。","next_action_hint":"下一轮可查公开 CAP challenge 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":39,"commit":"be5d067","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":76,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"auth_cache negative 缓存加上限防 DoS + relay/flared 删除重复 authenticateAccessToken 改用 agent 共享缓存版","timestamp":1787708052241,"segment":0,"confidence":null,"asi":{"hypothesis":"negative cache 无上限可被伪造 token 撑爆内存;relay/flared 与 agent 三份重复的 authenticateAccessToken","next_action_hint":"继续扫其他无界缓存/限流缺口","result":"metric 持平 8(8 个均为 deliberate keeper),安全修复不计入 metric","security":"negative cache 加 10k 上限+过期清理;relay/flared 复用 agent.AuthenticateAccessToken(共享 2min 正/10min 负缓存,DB 压力下降)"}}
|
||||
{"run":40,"commit":"0dd2cf9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 hub 去重:抽 runWritePump 共享写泵 + 合并 agent 广播函数为 broadcastAgent","timestamp":1787708370650,"segment":0,"confidence":null,"asi":{"hypothesis":"三份 hub 的 writePump 完全重复(仅日志前缀不同),readPump 已有 runReadPump 抽取先例;BroadcastWAFIPGroups/BroadcastActiveConfig 复制粘贴","next_action_hint":"close() 3 份小重复可再合并但收益低;继续找其他模块的重复/无界增长","result":"metric 持平 8,全测试绿","refactor":"新增 websocket/write_pump.go runWritePump(对齐 runReadPump 模式),agent/relay/flared writePump 改委托;agent_hub 抽 broadcastAgent 合并两个广播函数"}}
|
||||
{"run":41,"commit":"efd8268","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)","timestamp":1787708975609,"segment":0,"confidence":null,"asi":{"hypothesis":"agentClient/relayClient/flaredClient 字段与 close/enqueue 完全相同,用组合(嵌入 wsClientCore)消除三份重复","next_action_hint":"代码库经 40 轮已高度收敛;后续可周期性跑 go test -race 全量","result":"metric 持平 8,全测试绿;净减 ~60 行重复代码","refactor":"新增 websocket/client_core.go:wsClientCore(nodeID/conn/send/done/once) + 共享 close/enqueue;三个 client 结构体改为嵌入"}}
|
||||
{"run":42,"commit":"ed1efd3","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"补 wsClientCore 并发测试 + close() 防 nil conn 守卫","timestamp":1787709222794,"segment":0,"confidence":null,"asi":{"hypothesis":"wsClientCore 并发语义(close 幂等、enqueue 不阻塞/关后拒绝)无测试覆盖","next_action_hint":"websocket 包已有基础并发测试;继续其他模块扫描","result":"metric 持平 8;测试还暴露 close 未防 nil conn 的防御缺口,已补守卫","refactor":"新增 websocket/client_core_test.go 3 个 -race 测试;client_core.go close() 增加 nil conn 守卫"}}
|
||||
{"run":43,"commit":"4f8e7e6","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用","timestamp":1787709693698,"segment":0,"confidence":null,"asi":{"hypothesis":"frps/frpc TOML 配置用裸 Fprintf 拼接,token/password/域名含引号、反斜杠、换行时会破坏配置或注入键","next_action_hint":"检查其他配置生成点是否有同类注入面(nginx/openresty 配置)","result":"metric 回到 8;frpc 慢套件 16.8s 全绿;mnd 曾短暂+1(Grow 魔法数),删除微优化后消除","security":"新增 pkg/protocol/toml.go TOMLQuote 转义助手 + toml_test.go;relay/frps renderConfig 与 flared/frpc buildFrpcToml 全部插值改为转义输出"}}
|
||||
{"run":44,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固","timestamp":1787711092906,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 go test -race 可能暴露并发 bug(此前仅局部验证)","next_action_hint":"继续扫其他模块;可考虑把 -race 纳入周期性检查","result":"发现并修复 1 个真实 DATA RACE;修复后全仓 -race 0 竞争,metric 持平 8","root_cause":"upload/cache 监听器 goroutine 读可变全局 db.Redis,与 testhelper 清理置 nil 竞争;testhelper 导入 upload/cache 有循环依赖,故用启动时捕获客户端的根因修复(oauth/repository 同型监听器一并加固),并补 StopUploadMetaCacheListener 同步等待 done"}}
|
||||
{"run":45,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"探索轮:索引对齐/前端请求瀑布/BasicAuth 注入面三假设均证伪,无代码变更","timestamp":1787711474404,"segment":0,"confidence":null,"asi":{"hypothesis":"SQLite 迁移缺 PG 同款索引;前端存在串行请求瀑布;nginx BasicAuth 密码有注入面","next_action_hint":"代码库已高度收敛;下轮可考虑 observability 查询构造器审计或周期性重跑 -race","rollback_reason":"纯探索无代码变更,无需回滚","result":"三个假设均无产出:①索引对比(修正提取正则后)PG/SQLite 完全对齐,SQLite 仅多 legacy w_* 冗余索引;②前端 await Service 均在事件处理器非渲染期;③BasicAuth 密码经 base64 编码(字母表无元字符)无注入面","lessons":"grep 提取 SQL 时注意 IF NOT EXISTS 变体,否则产生假缺口"}}
|
||||
{"run":46,"commit":"2cb3392","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"LIKE 过滤器转义修复:日志搜索含 %/_ 的输入不再被当通配符;pkg/util 新增 EscapeLike 共享助手 + 单测","timestamp":1787712116152,"segment":0,"confidence":null,"asi":{"hypothesis":"日志搜索 LIKE 过滤器不转义 %/_/\\,含下划线的路径/主机名搜索结果错误","next_action_hint":"同类遗留站点(upload/user/task_execution GORM 搜索)已记 ideas.md,可作后续轮次","result":"修复 4 个站点:analytics 两处 CH 过滤器 + logstore postgres_store 两处(PG/SQLite 加 ESCAPE '\\')。新增 pkg/util/like.go EscapeLike + 单测。metric 持平 8,全部测试通过","scope_decision":"GORM 实体搜索站(upload keyword、user username/email)同 bug 类但低风险且可能依赖现有通配语义,本轮不动"}}
|
||||
{"run":47,"commit":"3528323","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复","timestamp":1787712555794,"segment":0,"confidence":null,"asi":{"hypothesis":"GORM 实体搜索站与 #46 日志搜索同 bug 类:LIKE 模式不转义通配符","next_action_hint":"LIKE 类已全部收尾;下轮可考虑 ideas.md 的测试可运行性方向或周期性全仓 -race 重跑","result":"6 站点修复(upload keyword、user username/email 前缀+contains、OAuth uniqueUsername base、task_type 前缀),PG/SQLite 加显式 ESCAPE。系统常量模式刻意保留(upload.go:199 image/%)。metric 持平 8,测试全绿","scope_decision":"uniqueUsername 的 base 来自 OAuth 用户信息属外部输入,含 _ 会误报用户名冲突——虽是系统生成后缀模式也需转义 base 本身"}}
|
||||
{"run":48,"commit":"55db1c0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口","timestamp":1787713583118,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 20 处后台 goroutine 裸跑零 recover,任一 panic 击穿 gin handler 级恢复直接崩溃进程","next_action_hint":"goroutine 收口完成;下轮可周期性 go test -race ./... 全量重跑(上次 #44)","result":"pkg/util.Go(fn) 共享助手(runtime.Caller 自动记录调用点 + slog + debug.Stack),22 个站点全部收口(含嵌套 watcher)。脚本转换两轮(首轮漏嵌套内层)。首次 checks_failed 因新文件缺 SPDX 头,update_go_license.sh 修复后全绿。metric 持平 8"}}
|
||||
{"run":49,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frpc restartProcess 发布未初始化 exec.Cmd 的数据竞争:proc.Cmd/Status 改为 Start 成功后加锁发布","timestamp":1787714358791,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 go test -race ./... 重跑(上次 #44 后又改了 repository/logstore/goroutine 站点)能抓出新数据竞争","next_action_hint":"-race 全仓清零;下轮候选:frontend axe a11y 审计,或 Go 1.26 新 linter 扫描","result":"全仓 -race 抓到 1 个真实 race:frpc/manager.go restartProcess 在 cmd.Start() 前就发布 proc.Cmd+Status=running(Start 中 cmd.Process 未赋值),测试读句柄与之竞争。修复=Start 成功后再加锁发布(manager.go:219-220 移入 err==nil 分支)。frpc 包 -race 连续 3 次通过。其余全仓 -race 干净"}}
|
||||
{"run":50,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"扩展 linter 发现扫描 + 热路径性能排查:errchkjson/unparam/spancheck 等 9 个新维度,全部核实为不可失败/刻意设计/误报","timestamp":1787714798689,"segment":0,"confidence":null,"asi":{"hypothesis":"基准外发现型 linter(errchkjson/unparam/spancheck/exptostd/durationcheck/makezero/reassign/asasalint/bidichk)+ 热路径性能 grep 能找到真实缺陷","next_action_hint":"发现型 linter 已穷尽;下轮候选:frontend axe a11y 浏览器级审计,或任务执行日志/DB 增长类运维审查","result":"全部证伪:errchkjson 12 处均核实为不可能失败的 marshal(纯 string/int/[]string 结构体;2 处 unsafe 标记是传递性保守);spancheck 1 处误报(唯一调用方 executor.go:242 有 defer span.End());unparam×2 为已评估的工厂签名设计;正则全在包级编译无热路径重编译;包级 map 全为有界静态注册表;AppendLog 走 DB 无内存累积。escapeJSONString 用法正确。无代码变更"}}
|
||||
{"run":51,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"运行时资源审计:HTTP 客户端超时覆盖 + 查询热路径索引覆盖,两项全部干净无缺陷","timestamp":1787715135724,"segment":0,"confidence":null,"asi":{"hypothesis":"运行时资源审计:出站 HTTP 客户端超时覆盖 + LIKE/精确匹配热路径的 DB 索引支撑","next_action_hint":"两项审计干净。剩余:frontend axe a11y(需起前端+浏览器)、周期性 -race 重跑、uploads LOWER(file_name) contains 若成为性能痛点需改前缀语义+表达式索引","result":"全部干净:15 个 http.Client 中 14 个显式 Timeout,唯一无 Timeout 的 agent/nginx checkStubStatus 走 NewRequestWithContext+WithTimeout 边界;users.username 全部精确匹配热路径由 UNIQUE 内联索引覆盖(PG+SQLite 均确认),email/task_type/logstore 过滤列均已有索引;uploads LOWER(file_name) contains 不可用 b-tree 但属管理端低频,改语义才有收益故不动"}}
|
||||
{"run":52,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":71,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"SQL 注入面 + Go 运行时陷阱模式 + react-hooks 依赖三重审计,全部干净无缺陷","timestamp":1787715503278,"segment":0,"confidence":null,"asi":{"hypothesis":"原始 SQL 拼接注入面 + 经典 Go 运行时陷阱(time.After 循环泄漏/defer-in-loop/context.Background 丢失取消)+ 前端 react-hooks 依赖正确性","next_action_hint":"静态+运行时审计维度已穷尽。剩余唯一大项:frontend axe a11y 浏览器级审计(需起前端 dev server + agent_browser)","result":"全部干净:db_manage SQL 控制台为管理端允许例外且表名双引号转义正确、analytics Sprintf 均内部常量表名+参数化占位符;time.After 仅 3 处且均为 select 单次等待/有界重试;defer 均在函数级非循环内;19 处 context.Background() 全部为后台监听器(WithCancel)/重启路径/自带超时的清理任务,无请求 ctx 丢弃;react-hooks/exhaustive-deps 全仓零违规(CLI 临时规则,未改配置)"}}
|
||||
{"run":53,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"前端 axe a11y 浏览器审计:唯一违规为无后端环境产物,无代码缺陷","timestamp":1787716027952,"segment":0,"confidence":null,"asi":{"hypothesis":"前端 axe-core 浏览器级 a11y 审计(最后一个未探索大维度)","next_action_hint":"a11y 维度已探索但受登录墙限制:完整审计需起后端+种子账号登录。若未来重跑:起 Go 后端 + admin 登录后逐页 axe.run","result":"agent-browser 0.34.0 已装好可复用。axe 审计覆盖所有无认证可达页面(/login、/register、/docs/* 全被登录墙拦截):唯一违规 page-has-heading-one 是环境产物——后端未启动时页面卡在 session-check/publicConfig-pending 态只渲染 Spinner,真实表单的 AuthHeading h1 未渲染;瞬态态用 h3 属可接受的瞬态层级。无代码缺陷。已认证页面需后端才能审计"}}
|
||||
{"run":54,"commit":"451ce52","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":93,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"认证页 axe a11y 审计+修复:7 处布局级真实违规全修,复扫验证 dashboard/admin/system 归零;基准 total_issues 保持 8 不变(纯质量收益)","timestamp":1787718397798,"segment":0,"confidence":null,"asi":{"hypothesis":"认证页 axe a11y 审计(起后端+登录突破登录墙):修复布局级真实违规","next_action_hint":"已验证 / 与 /admin/system 归零。剩余页面级:admin 表格行内操作按钮/Switch 无 aria-label、muted 文本对比度——需逐表补标签,工作量大已归档 ideas.md","result":"修复 7 处全局问题并复扫验证:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 违规 18 节点/页清零)、header Kbd 对比度 text-foreground/70(每页 1 处)、dashboard 4 个 Progress aria-label、分页按钮 aria-label、空态/错误/加载 h3→p(heading-order 清零)、admin/system 无内容 Tabs 改 aria-pressed 按钮组(aria-valid-attr-value critical 清零)。dashboard 与 admin/system 现 0 违规","setup":"审计环境:后端 go run . api @:3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite+redis host 网络 docker)、前端 pnpm dev --port 3002(WAVELET_BACKEND_URL=:3100)、admin 密码经 reset-passwd 重置"}}
|
||||
{"run":55,"commit":"e66dea9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"a11y 收尾:主题级对比度根因修复(indigo-500→600)+12 处控件 accessible name+4 处 heading-order,7 页复扫全 0 违规;基准 total_issues 保持 8","timestamp":1787719908229,"segment":0,"confidence":null,"asi":{"hypothesis":"页面级 a11y 批量收尾:主题级 color-contrast 根因 + 表格/表单控件 accessible name","next_action_hint":"7 页复扫全 0 违规。剩余:其余页面(websites/origins/cloudflare 等仅扫过 contrast 已由主题修复覆盖)可抽查;-race 周期重跑","result":"根因1:--primary indigo-500(#6366f1) 对 #fafafa 仅 4.27 → 改 indigo-600 oklch(51.1% 0.262 276.966)(~6.8 AA),全站 contrast 清零(一处主题修复覆盖所有页面)。修复 12 处控件名:access-analytics 刷新按钮、events-tab Switch/edit/delete、openflare-ops ToggleRow Switch+geoip/kuma Select+FieldInput Input htmlFor+discovery Textarea、table-browser/sql-console SelectTrigger;heading-order:cache-manager/user-detail-sheet h4→p、task-manager h3→p、file-manager noFiles h3→p;新增 admin.logs.analytics.refresh i18n 键(en/zh)+merge-i18n-fragments。教训:settings 表单异步渲染,早前扫描漏报 label 违规需 wait 5s 后再 axe.run;Radix SelectValue value='' 时 placeholder 不显示致 combobox 无名,须 aria-label 兜底","setup":"审计环境同 run#54:后端:3100(sqlite) + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":56,"commit":"63e3b85","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8","timestamp":1787720716912,"segment":0,"confidence":null,"asi":{"hypothesis":"富交互页抽查(websites/origins/proxy-routes/certificates/cloudflare/dns-accounts/settings 子页)","next_action_hint":"11 页扫描全部归零,a11y 维度已穷尽。剩余:周期性 -race 重跑;审计环境复用法在 ideas.md","result":"websites/origins/proxy-routes/certificates/dns-accounts 5 页直接 0 违规(主题修复覆盖);3 处新发现全修复并复扫验证:cloudflare 同步面板状态筛选 SelectTrigger 加 aria-label(statusPlaceholder);access-token 安全提示 amber-600→amber-700(12px 小字对比度 4.5 不达标);notifications 面包屑页加 sr-only h1——教训:h1 不能放 BreadcrumbList 内(破坏 list 语义 axe list 规则),BreadcrumbPage 无 asChild 需放 Breadcrumb 外","setup":"审计环境同前:后端:3100 + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":57,"commit":"453f7e5","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理","timestamp":1787721299485,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 -race 重跑(上次干净为 run #49)","next_action_hint":"websocket 包 -race 10×count=1 全过。教训已记录:select 多 case 同时就绪时随机选择,closed 检查须独立 select 先行;replace 工具锚点选错会级联破坏文件,小文件直接 write 重写更安全","root_cause":"enqueue 把 closed 检查与发送合并在同一个 select,两 case 同时就绪时 Go 随机选择,close 后约 50% 概率仍投递成功——违反 fail-fast 契约且测试 flaky。修复=独立 select 确定性先查 done;测试加固为循环 50 次","result":"抓到真实 bug:wsClientCore.enqueue close 后非确定返回 true(TestWSClientCoreEnqueueFailsAfterClose 必失败)。调用方 agent_hub×3 语义无影响(false=丢弃本就正确)。顺带修 3 个 hub 文件存量 gofmt 漂移","scope_note":"-race 重跑仅 websocket 包 1 个 FAIL,其余 internal/... pkg/... 全部通过"}}
|
||||
{"run":58,"commit":"fc733d0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复","timestamp":1787721635717,"segment":0,"confidence":null,"asi":{"hypothesis":"#57 修复 enqueue 后,grep 全 hub 同型合并 select——发现 SendFlaredPong/SendRelayPong 残留相同 bug","lesson":"修一个 bug 后应 grep 所有同型调用点(本会话 run #44/#46/#57 三次都是同型残留收口模式);委托共享 enqueue 是去重+根因一步到位","next_action_hint":"websocket 并发面已全清。下轮可做:周期性全仓 -race 或 go test -count=10 稳定性抽查","root_cause":"SendFlaredPong (flared_hub.go) 与 SendRelayPong (relay_hub.go) 把 case <-client.done 与 case client.send <- 合并同一 select,两 case 同时就绪时 Go 随机选择,close 后仍可能投递成功。修复=委托 client.enqueue(内含确定性先查 done),同时消除重复代码"}}
|
||||
{"run":59,"commit":"b56f276","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复","timestamp":1787722520315,"segment":0,"confidence":null,"asi":{"hypothesis":"-shuffle=on 测试顺序随机化扫描(未查过的维度),暴露测试间共享状态依赖","lesson":"repository 读配置会写进程级 RAM 缓存(ram.Set,TTL 跨测试存活);测试用 :memory: DB + SetDB 换库时缓存不随之失效。默认源码顺序下 Defaults 先跑掩盖了问题。-shuffle=on 是暴露此类顺序依赖的低成本手段,可周期重跑","next_action_hint":"全仓 shuffle 已干净。下轮候选:-count 多轮稳定性、或从 ideas.md 剩余条目挑;明确不做清单见 ideas.md","root_cause":"TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 Custom 用例留在进程级 RAM 配置缓存的 enabled=false/[\"522\",\"500-502\"](GetSystemConfigByGroup 未命中时 ram.Set 回填)。修复=两个测试 setup(setupOriginErrorPageSnapshotDB/setupConfigVersionTestDB)接入既有 ram.ResetForTest():换 DB 前后各清一次"}}
|
||||
|
||||
+81
-37
@@ -1,46 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Autoresearch measure — pinned yardstick.
|
||||
# debt : findings under .auto/lint.ref.yaml (lower is better) [PRIMARY]
|
||||
# nolint_dirs : raw //nolint directive count (lower is better, floor 0)
|
||||
# tests_passed : go test packages passing (floor, must never drop)
|
||||
# test_funcs : total Test*/Benchmark* funcs (floor, must never drop)
|
||||
# arch_viol : Cordis architecture script violations (floor 0)
|
||||
# coverage : backend statement coverage % (informational)
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/../backend"
|
||||
# Benchmark: total code-quality issues across backend + frontend (lower is better).
|
||||
# Fixed linter set — see .auto/prompt.md. Never tune this file to game counts.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
start=$(date +%s)
|
||||
|
||||
# Hermetic lint result cache. golangci-lint's default cache is machine-wide, so
|
||||
# entries written while analysing a different worktree are replayed carrying that
|
||||
# checkout's absolute paths, which misattributes findings and can serve a stale
|
||||
# verdict. Key the cache to this directory. Count-neutral: cold and shared-warm
|
||||
# runs both report the same number of findings.
|
||||
GOLANGCI_LINT_CACHE="${TMPDIR:-/tmp}/ar-lint-cache-$(pwd | cksum | awk '{print $1}')"
|
||||
export GOLANGCI_LINT_CACHE
|
||||
# ---------- Backend: golangci-lint, repo config + fixed best-practice extras ----------
|
||||
EXTRA_LINTERS="errorlint,errname,nilnil,forcetypeassert,copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign,exhaustive"
|
||||
golang_out=$(golangci-lint run --enable="$EXTRA_LINTERS" 2>&1 || true)
|
||||
|
||||
REF_CFG="$(cd .. && pwd)/.auto/lint.ref.yaml"
|
||||
golang_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_total=$((golang_total + n))
|
||||
echo "METRIC golint_${name}=$n"
|
||||
fi
|
||||
done <<< "$golang_out"
|
||||
echo "METRIC golint_total=$golang_total"
|
||||
|
||||
# Primary: pinned yardstick findings (never the mutable project config).
|
||||
golangci-lint run -c "${REF_CFG}" > /tmp/ar_debt.txt 2>&1 || true
|
||||
DEBT=$(grep -cE '\.go:[0-9]+:[0-9]+: ' /tmp/ar_debt.txt || true)
|
||||
# ---------- Backend: test-code quality (tests excluded from repo config; safe linters only) ----------
|
||||
test_out=$(golangci-lint run --tests=true --enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper 2>&1 || true)
|
||||
golang_test_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_test_total=$((golang_test_total + n))
|
||||
echo "METRIC golint_test_${name}=$n"
|
||||
fi
|
||||
done <<< "$test_out"
|
||||
echo "METRIC golint_test_total=$golang_test_total"
|
||||
|
||||
# Suppression reliance — anti-cheat signal.
|
||||
NOLINT=$(rg '//\s*nolint' --glob '*.go' 2>/dev/null | wc -l | tr -d ' ')
|
||||
# ---------- Backend: govet extra analyzers (dead code / nil deref — real-bug finders) ----------
|
||||
cat > /tmp/govetx.yml <<'EOF'
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- govet
|
||||
settings:
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
- unusedwrite
|
||||
EOF
|
||||
vetx_out=$(golangci-lint run --config /tmp/govetx.yml --max-issues-per-linter=0 2>&1 || true)
|
||||
rm -f /tmp/govetx.yml
|
||||
golang_vetx_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_vetx_total=$((golang_vetx_total + n))
|
||||
echo "METRIC golint_vetx_${name}=$n"
|
||||
fi
|
||||
done <<< "$vetx_out"
|
||||
echo "METRIC golint_vetx_total=$golang_vetx_total"
|
||||
|
||||
# Regression floors. One covered run feeds both the pass floor and coverage.
|
||||
go test -cover ./... > /tmp/ar_test.txt 2>&1 || true
|
||||
TESTS_PASSED=$(grep -c '^ok' /tmp/ar_test.txt || true)
|
||||
FAILS=$(grep -cE '^(FAIL|--- FAIL)' /tmp/ar_test.txt || true)
|
||||
TEST_FUNCS=$(rg -c '^(func Test|func Benchmark)' --glob '*_test.go' 2>/dev/null | awk -F: '{s+=$2} END {print s+0}')
|
||||
# ---------- Frontend: eslint (repo gate) ----------
|
||||
cd frontend
|
||||
eslint_out=$(pnpm exec eslint . --max-warnings 0 2>&1 || true)
|
||||
eslint_problems=0; eslint_errors=0; eslint_warnings=0
|
||||
if [[ "$eslint_out" =~ ([0-9]+)\ problems? ]]; then eslint_problems="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ \(([0-9]+)\ errors?, ]]; then eslint_errors="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ ,\ ([0-9]+)\ warnings? ]]; then eslint_warnings="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC eslint_problems=$eslint_problems"
|
||||
echo "METRIC eslint_errors=$eslint_errors"
|
||||
echo "METRIC eslint_warnings=$eslint_warnings"
|
||||
|
||||
# Cordis architecture violations (count of FAIL lines emitted by the gate script).
|
||||
ARCH_VIOL=$("../scripts/check_cordis_architecture.sh" 2>&1 | grep -c 'FAIL' || true)
|
||||
# ---------- Frontend: tsc (repo gate) ----------
|
||||
tsc_out=$(pnpm exec tsc --noEmit --jsx preserve 2>&1 || true)
|
||||
tsc_errors=$(grep -cE "error TS" <<< "$tsc_out" || true)
|
||||
echo "METRIC tsc_errors=$tsc_errors"
|
||||
|
||||
COVERAGE=$(grep -oE 'coverage: [0-9.]+%' /tmp/ar_test.txt | awk '{gsub("%","",$2); s+=$2; n++} END {if(n>0) printf "%.2f", s/n; else print "0"}')
|
||||
# ---------- Frontend: vitest (2026-08-16 起全绿,纳入基准防回归) ----------
|
||||
vitest_out=$(pnpm exec vitest run --reporter=dot 2>&1 || true)
|
||||
vitest_failed=0; vitest_total=0
|
||||
if [[ "$vitest_out" =~ ([0-9]+)\ failed ]]; then vitest_failed="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+)\ passed ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+) ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC vitest_failed=$vitest_failed"
|
||||
echo "METRIC vitest_total=$vitest_total"
|
||||
|
||||
echo "METRIC debt=${DEBT}"
|
||||
echo "METRIC nolint_dirs=${NOLINT}"
|
||||
echo "METRIC tests_passed=${TESTS_PASSED}"
|
||||
echo "METRIC test_funcs=${TEST_FUNCS}"
|
||||
echo "METRIC arch_viol=${ARCH_VIOL}"
|
||||
echo "METRIC coverage=${COVERAGE}"
|
||||
echo "INFO test_failures=${FAILS}"
|
||||
end=$(date +%s)
|
||||
total=$((golang_total + golang_test_total + golang_vetx_total + eslint_problems + tsc_errors + vitest_failed))
|
||||
echo "METRIC total_issues=$total"
|
||||
echo "METRIC measure_s=$((end - start))"
|
||||
+155
-34
@@ -1,48 +1,169 @@
|
||||
# Autoresearch: Cordis 架构合规 + Bug 修复 + 性能 + 代码质量
|
||||
# Autoresearch: 前后端代码质量符合最佳代码实践
|
||||
|
||||
## Objective
|
||||
Wavelet 后端(Go, Cordis 插件化微内核架构)全面质量提升:修复违反 Cordis 设计原则的地方、修复 bug、消除潜在性能问题、减少代码重复(dupl)、提升可维护性。主指标为 golangci-lint 问题总数,逐实验递减,且不得以作弊手段(nolint / 弱化配置 / 删测试)达成。
|
||||
|
||||
Improve backend (Go) and frontend (Next.js/TS) code quality so the codebase
|
||||
conforms to best practices. NOT a performance task. Each experiment is a code
|
||||
change that removes real, lint-diagnosed code-quality issues (dead assignments,
|
||||
error-wrapping bugs, non-idiomatic loops, mixed receivers, unsafe error
|
||||
comparisons, unnecessary string fmt, etc.) without changing behavior.
|
||||
|
||||
Genuine quality work only: fix code, never weaken the checks. Do NOT edit
|
||||
`.golangci.yml`, eslint/biome config, or add `nolint`/`eslint-disable`
|
||||
comments to reduce counts. Do NOT reformat code that isn't part of a fix
|
||||
(no formatted-only churn).
|
||||
|
||||
## Metrics
|
||||
- **Primary**: lint_issues (count, lower is better) — `golangci-lint run` 报告的问题总数(基线 45)
|
||||
- **Secondary**: dup_issues (dupl 专项计数), tests_passed (go test 通过包数,不得下降)
|
||||
|
||||
- **Primary**: `total_issues` (unitless, lower is better) = backend golangci
|
||||
issues (extended linter set below) + frontend eslint problems + tsc errors.
|
||||
- **Secondary**: per-linter counts (`golint_modernize`, `golint_perfsprint`,
|
||||
`golint_errorlint`, `golint_gosec`, `golint_canonicalheader`,
|
||||
`golint_recvcheck`, `golint_wastedassign`, `golint_usestdlibvars`,
|
||||
`golint_intrange`, `golint_forcetypeassert`, `golint_nilnil`,
|
||||
`golint_prealloc`, `golint_errname`, `golint_sloglint`,
|
||||
`golint_copyloopvar`, `golint_mirror`, `golint_nosprintfhostport`),
|
||||
`eslint_problems`, `eslint_errors`, `eslint_warnings`, `tsc_errors`,
|
||||
`measure_s` (benchmark wall time).
|
||||
|
||||
## How to Run
|
||||
`./.auto/measure.sh` — 输出 `METRIC lint_issues=N` / `METRIC dup_issues=N` / `METRIC tests_passed=N`。
|
||||
|
||||
`./.auto/measure.sh` — outputs `METRIC name=value` lines. Parsed by
|
||||
run_experiment automatically.
|
||||
|
||||
Correctness gate: `./.auto/checks.sh` runs `go vet ./...`, `go build ./...`,
|
||||
and the repo's own `golangci-lint run` (repo config, tests excluded) — all
|
||||
must pass. Note: `go test ./...` is NOT in checks.sh — several tests fail on
|
||||
main today for environmental reasons (no local redis; flaky frpc process
|
||||
tests). Don't "fix" those unless cheap and clearly unrelated to redis/flaky.
|
||||
|
||||
## Benchmark Definition (fixed — never change mid-session)
|
||||
|
||||
Backend: `golangci-lint run --enable=errorlint,errname,nilnil,forcetypeassert,
|
||||
copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,
|
||||
sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign`
|
||||
(repo `.golangci.yml` linters stay active too; `tests: false` as configured).
|
||||
|
||||
Frontend: `pnpm exec eslint . --max-warnings 0` (repo gate) +
|
||||
`pnpm exec tsc --noEmit --jsx preserve` (repo gate).
|
||||
|
||||
Test-code dimension (added 2026-08-16, run #12+, documented scope extension —
|
||||
raising the bar, not gaming): `golangci-lint run --tests=true
|
||||
--enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper`
|
||||
counts test-file quality. DELIBERATELY excludes paralleltest/tparallel
|
||||
(t.Parallel advice is unsafe here: many suites share DB/redis state and tests
|
||||
cannot be run in this env) and gocritic extras (noise). Fix test issues only
|
||||
when compile-safe (go vet compiles tests) and semantically neutral.
|
||||
|
||||
Frontend vitest dimension (added run #19, after suite went green in run #18):
|
||||
`pnpm exec vitest run --reporter=dot` — `vitest_failed` counts into total.
|
||||
The suite is fully runnable locally (jsdom + mocks; no external services).
|
||||
Do not add/remove linters or change settings to make the number go down.
|
||||
|
||||
## Files in Scope
|
||||
- `backend/core/` 微内核(context.go/container.go/events.go/app.go/extpoints/contracts)— 只允许更纯粹,禁止引入框架依赖
|
||||
- `backend/plugins/{domain,infra,drivers}/` 所有插件 — 重复代码消除、bug 修复
|
||||
- `backend/pkg/` 基础库
|
||||
- `scripts/check_cordis_architecture.sh` 架构守门脚本(只可增强、不可弱化)
|
||||
- `.auto/*` 会话文件
|
||||
|
||||
## Off Limits(作弊红线)
|
||||
- ❌ 禁止修改 `.golangci.yml` 弱化 lint(禁阈值调高、禁关 linter、禁 excludes)
|
||||
- ❌ 禁止 `//nolint` 注释压制问题
|
||||
- ❌ 禁止删除测试或功能来消除告警(删除死代码需先证明确实无人引用)
|
||||
- ❌ 禁止违反 `backend/pkg/util/` 纯净性、`backend/core/` 微内核纯净性、contracts 纯抽象
|
||||
- ❌ 修复行为 bug 必须带回归测试或明确论证;tests_passed 不得下降
|
||||
- ✅ 允许:重构提取共享 helper(插件内)、加注释、常量化字符串、拆复杂嵌套、修 gosec 权限、优化 SQL/锁/分配
|
||||
Backend (Go): `cmd/`, `internal/`, `pkg/`. Anything lint-flagged in the
|
||||
extended set above. Note: module name in go.mod is `github.com/Rain-kl/Wavelet`.
|
||||
|
||||
Frontend (TS/React): `frontend/app/`, `frontend/components/`, `frontend/lib/`,
|
||||
`frontend/contexts/`, `frontend/hooks/`, `frontend/types/`, frontend scripts.
|
||||
|
||||
Infra: `frontend/pnpm-workspace.yaml` — approved @parcel/watcher + @swc/core
|
||||
builds (fixes `make code-check` under pnpm 11; ERR_PNPM_IGNORED_BUILDS
|
||||
otherwise). Already committed in setup.
|
||||
|
||||
## Off Limits
|
||||
|
||||
- `.golangci.yml`, `eslint.config.mjs`, `biome.json` — never touch to reduce counts.
|
||||
- No `//nolint` / `eslint-disable` comments to silence checks.
|
||||
- No reformat-only commits (biome/gofmt churn without a fix).
|
||||
- No behavior changes: refactors must compile (checks.sh gate) and keep tests
|
||||
semantics identical. Re-run checks.sh after every edit.
|
||||
- `frontend/node_modules`, `frontend/bun.lock` (untracked, not ours).
|
||||
- Do not run `go test` suites that need redis/network to declare success.
|
||||
|
||||
## Constraints
|
||||
- `.auto/checks.sh` 必须通过:`go build` + `go test ./...` + Cordis 架构检查全绿
|
||||
- 插件间严禁跨包 import,只能走 `core/contracts` + EventBus
|
||||
- 裸 `go func()` 禁止,统一 `util.Go`;SQL LIKE 必须 `util.EscapeLike` + `ESCAPE '\\'`
|
||||
- API Handler 改动后跑 `make swagger`(若改了 handler 签名/路由)
|
||||
|
||||
- Backend conventions (AGENTS.md): apps → repository → model layering;
|
||||
`pkg/util/` must not import Gin/GORM/sessions; no `db.DB` in model;
|
||||
response.Abort* for API errors; Chinese docs for content changes
|
||||
(code-quality fixes are not content changes — no doc sync needed unless
|
||||
behavior/UX changes; changelog only for user-visible changes, typically
|
||||
none here).
|
||||
- Frontend: run `pnpm exec biome format --write` only on files you edit
|
||||
(repo `make format` uses biome); keep component placement rules.
|
||||
- `golangci-lint --fix` is allowed and preferred for safe fixes
|
||||
(modernize/intrange/perfsprint/usestdlibvars/canonicalheader/mirror/
|
||||
copyloopvar/sloglint/errname) — review the resulting diff before keeping.
|
||||
For no-fix linters (errorlint wrapping, wastedassign, recvcheck, nilnil,
|
||||
prealloc, forcetypeassert) edit by hand.
|
||||
|
||||
## Workflow per iteration
|
||||
|
||||
1. Read current measure output: which categories remain, where.
|
||||
2. Pick ONE category (or a coherent set of similar fixes), locate files, fix
|
||||
by hand or with golangci-lint --fix scoped to that category.
|
||||
3. `./.auto/measure.sh` → if total dropped → `./.auto/checks.sh` → log keep.
|
||||
If flat/worse → discard or adjust.
|
||||
|
||||
## What's Been Tried
|
||||
### 基线状态(2026-08-28, 45 lint issues)
|
||||
- **nilerr 真 bug 候选**: `backend/plugins/domain/admin/repository.go:507` err!=nil 却 return nil
|
||||
- **contextcheck**: `driver_inproc_cron/plugin.go:70`、`driver_inproc_worker/plugin.go:133` 未传 context
|
||||
- **dupl 重复块**: core/extpoints{migration,setting,schedule,task} 四处同构注册代码;message_gateway{admin_handlers:55-67,115-130,143-158 ↔ push_handlers:61-74,77-93 ↔ push_channels:218-231,270-286}; message_gateway/repository.go:222-240↔332-350; driver_asynq_worker/plugin.go:371-390↔420-439
|
||||
- **nestif 深嵌套**: admin/handlers_config.go:442(complexity 6), upload/filesrv/file_server.go:330(6), driver_asynq_cron/plugin.go:142(9), driver_asynq_cron/scheduler.go:119(5)
|
||||
- **gosec**: upload/shared/test_helpers.go:133(G301),134(G306),152(G304); infra/database/postgres.go:49(G301)
|
||||
- **goconst**: "sqlite"(admin/handlers_db.go:150,handlers_status.go:179), "upload"/"default"(upload/task/*)
|
||||
- **revive**: storage.go:53 缺注释, db_helper.go{admin:118,125; message_gateway:25; upload/storage/migration.go:40} 未用参数, handlers_config.go:510 未用参数
|
||||
- **mnd**: pkg/cache/disk/cache.go:100 魔法数 10
|
||||
- **gofumpt**: upload/stats/{category,stats_counter}.go, driver_http/db_helper.go 格式错误
|
||||
- 前端 eslint/tsc 已全绿;架构检查脚本 0 违规
|
||||
|
||||
### 教训
|
||||
- (空 — 随实验更新)
|
||||
- Setup commit `ee6974d` (autoresearch/code-quality-2026-08-16): branch,
|
||||
.auto/ session files, frontend/pnpm-workspace.yaml build approvals.
|
||||
- Baseline (before any code fix): total_issues = 108
|
||||
(golangci 107 = modernize 37, perfsprint 18, errorlint 12, canonicalheader 8,
|
||||
recvcheck 7, wastedassign 7, usestdlibvars 3, intrange 3, forcetypeassert 3,
|
||||
nilnil 3, prealloc 3, errname 1, gosec 2; eslint 1 warning
|
||||
[react-hooks/exhaustive-deps in
|
||||
app/(main)/pages/detail/components/pages-source-card.tsx:275]; tsc 0).
|
||||
- Environment notes: golangci-lint 2.12.2 warm cache ~3s; eslint cold ~27s
|
||||
(ignore stderr pnpm noise); go vet+go build ~15-30s after edits.
|
||||
|
||||
### 最终状态(run #23,提交 aa4fadda,本会话收敛点)
|
||||
|
||||
基准 5 维全下限 total=8(全为刻意保留);后端 94 包 + 前端 vitest 116 全绿;
|
||||
`go test -race ./internal/... ./pkg/...` 93 包零警告;`make build-embedded`
|
||||
(发布路径)成功且工作树干净;`make license-check` / `go mod tidy -diff` /
|
||||
`go test -count=3`(时序敏感包)全部通过。checks.sh 门禁:vet + build +
|
||||
golangci + 单测 + vitest + 并发包 -race + license-check。
|
||||
|
||||
### Session result (14 experiments, commits f1f6bb85→65c02ef7)
|
||||
|
||||
108 → **8** (-92.6%) across 3 benchmark dimensions, all remaining 8 are
|
||||
deliberate, documented keepers (see below). Never weakened a check; never
|
||||
added nolint/eslint-disable; benchmark extensions were transparently
|
||||
documented (test-code dimension run #12, exhaustive run #14).
|
||||
|
||||
Fixed (zero behavior change, each reviewed):
|
||||
- gosec 2→0 (saturating multiply pattern gosec accepts without nolint)
|
||||
- modernize 37→5→3 (any, max/min, slices/maps, strings.Cut/SplitSeq,
|
||||
strings.Builder; omitted omitted-lark: nested struct omitzero = wire change)
|
||||
- perfsprint 18→0, canonicalheader 8→0, usestdlibvars 3→0, intrange 3→0,
|
||||
wastedassign 7→0, errname 1→0, forcetypeassert 6→0, prealloc 2→0
|
||||
- errorlint 12→1 (errors.Is/As, %v→%w chains)
|
||||
- recvcheck 7→1 (GORM TableName → pointer receiver; verified gorm source uses
|
||||
reflect.New, tests pass)
|
||||
- eslint 1→0 (exhaustive-deps: add stable `t` to dep array)
|
||||
- test dimension 25→0 (testifylint 20, thelper 3, usetesting 2)
|
||||
- exhaustive 12→0 (explicit enum cases = fail-explicit)
|
||||
|
||||
Deliberate keepers (8) — do NOT "fix" without new evidence:
|
||||
- errorlint 1: pkg/push/telegram.go %v — wrapping the original error would
|
||||
change errors.Is matching semantics; it's intentionally textual context.
|
||||
- modernize 3: nested-struct omitempty (client.go Release/Asset,
|
||||
lark.go Content) — omitzero would CHANGE wire output (plain structs
|
||||
serialize always today).
|
||||
- nilnil 3: not-found/optional-result conventions — postgres_store.go
|
||||
ClickHouseOperationalStats (interface contract, documented in comment),
|
||||
openflare_apply_log.go GetLatestOpenFlareApplyLogByNodeID (tested),
|
||||
github_source_action.go guarded outcome (callers check != nil).
|
||||
- recvcheck 1: MillisecondDuration — encoding/json requires Marshal value
|
||||
receiver + Unmarshal pointer receiver.
|
||||
|
||||
Surveyed and rejected (noise/risk, do not add):
|
||||
- fieldalignment (~100+): JSON key order change + positional literal risk.
|
||||
- sloglint full / gocritic extras: 0 findings.
|
||||
- paralleltest/tparallel: t.Parallel advice unsafe (shared DB/redis state;
|
||||
tests not runnable in this env).
|
||||
- biome format drift (76 files): pure formatting noise; repo's make format
|
||||
covers it.
|
||||
+15
-9
@@ -1,21 +1,27 @@
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
.github
|
||||
anubis-source
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/build
|
||||
**/dist
|
||||
**/.cache
|
||||
**/coverage
|
||||
**/*.db
|
||||
**/*.log
|
||||
tmp
|
||||
logs
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
config.yaml
|
||||
.env
|
||||
.env.*
|
||||
|
||||
docker-compose*.yml
|
||||
config.yaml
|
||||
bin/
|
||||
build/
|
||||
dist/
|
||||
data/
|
||||
logs/
|
||||
uploads/
|
||||
s3_cache/
|
||||
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
@@ -25,6 +31,6 @@ frontend/.env
|
||||
frontend/next-env.d.ts
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/package-lock.json
|
||||
|
||||
internal/router/dist/
|
||||
internal/router/root/dist/
|
||||
backend/plugins/drivers/driver_http/dist/
|
||||
|
||||
+22
-18
@@ -1,5 +1,5 @@
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# wavelet — 环境变量配置模板
|
||||
# openflare — 环境变量配置模板
|
||||
# 复制此文件为 .env 并填入实际值: cp .env.example .env
|
||||
# 环境变量优先级高于 config.yaml
|
||||
# docker compose 会读取本文件(env_file: .env)并替换 compose 中的 ${VAR}
|
||||
@@ -9,13 +9,13 @@
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ─── 应用配置 ──────────────────────────────────────────────────────────────────
|
||||
APP_NAME=wavelet
|
||||
APP_NAME=openflare
|
||||
APP_ENV=production
|
||||
APP_ADDR=:8000
|
||||
APP_ADDR=:3000
|
||||
APP_NODE_ID=1
|
||||
APP_API_PREFIX=/api
|
||||
# APP_GRACEFUL_SHUTDOWN_TIMEOUT=30
|
||||
APP_SESSION_COOKIE_NAME=wavelet_session_id
|
||||
APP_SESSION_COOKIE_NAME=openflare_session_id
|
||||
APP_SESSION_SECRET=change-me-to-a-random-string-in-production
|
||||
# APP_SESSION_DOMAIN=
|
||||
APP_SESSION_AGE=86400
|
||||
@@ -27,12 +27,13 @@ APP_SESSION_SECURE=true
|
||||
# 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制
|
||||
# DB_ENABLED=false 时使用 SQLite 作为后备数据库
|
||||
DB_ENABLED=true
|
||||
# SQLITE_PATH=./data/wavelet.db
|
||||
# SQLITE_PATH=./data/openflare.db
|
||||
# compose 内应用连服务名;本机直连 Docker 映射端口时用 127.0.0.1
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=wavelet
|
||||
DB_USERNAME=openflare
|
||||
DB_PASSWORD=replace-with-strong-password
|
||||
DB_NAME=openflare
|
||||
DB_SSL_MODE=disable
|
||||
DB_TIMEZONE=Asia/Shanghai
|
||||
# DB_LOG_LEVEL=info
|
||||
@@ -46,20 +47,23 @@ REDIS_ADDR=redis:6379
|
||||
# REDIS_USERNAME=
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_DB=0
|
||||
REDIS_KEY_PREFIX=wavelet:
|
||||
REDIS_KEY_PREFIX=openflare:
|
||||
# REDIS_POOL_SIZE=100
|
||||
# 启动时开关;修改后需重启服务
|
||||
REDIS_MAINT_NOTIFICATIONS=false
|
||||
# compose 宿主机映射端口(仅 docker-compose 使用)
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# ─── ClickHouse(可选,默认关闭)──────────────────────────────────────────
|
||||
# 设置 CLICKHOUSE_HOST 后自动启用,也可显式控制
|
||||
# CLICKHOUSE_ENABLED=false
|
||||
# CLICKHOUSE_HOST=clickhouse:9000
|
||||
# CLICKHOUSE_USERNAME=default
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
# CLICKHOUSE_NAME=wavelet
|
||||
# ─── ClickHouse(必需)────────────────────────────────────────────────────────
|
||||
# CLICKHOUSE_HOST 设置后会自动启用;测试环境可显式 CLICKHOUSE_ENABLED=true 做 live 联调
|
||||
CLICKHOUSE_ENABLED=false
|
||||
# compose 内:clickhouse:9000;本机连映射端口:127.0.0.1:9000
|
||||
CLICKHOUSE_HOST=clickhouse:9000
|
||||
CLICKHOUSE_USERNAME=default
|
||||
# 须与 compose clickhouse 服务密码一致(首次初始化后改密码需清 data/clickhouse_data)
|
||||
CLICKHOUSE_PASSWORD=replace-with-clickhouse-password
|
||||
CLICKHOUSE_NAME=openflare
|
||||
|
||||
|
||||
# ─── 日志 ──────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
@@ -72,8 +76,8 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
|
||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
# 设为 0 关闭 tracing;本地 Jaeger 调试建议设为 1.0
|
||||
OTEL_SAMPLING_RATE=0.0
|
||||
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/Wavelet
|
||||
# OTEL_TRACER_NAME=github.com/Rain-kl/Wavelet
|
||||
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare
|
||||
# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare
|
||||
# compose 可选端口覆盖
|
||||
# JAEGER_VERSION=2.19.0
|
||||
# JAEGER_UI_PORT=16686
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
* -text
|
||||
backend/openflare/** merge=ours
|
||||
frontend/** merge=ours
|
||||
docs/changelog/** merge=ours
|
||||
docs/superpowers/** merge=ours
|
||||
.github/workflows/build-image.yml merge=ours
|
||||
docker-compose.yml merge=ours
|
||||
.gitconfig merge=ours
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Repo-local Git settings. Git does not load this file automatically.
|
||||
# From the clone (or worktree) root:
|
||||
# git config include.path ../.gitconfig
|
||||
# Worktree-safe:
|
||||
# git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
# Relative include.path is resolved against .git/config, so ../.gitconfig
|
||||
# is the repo root when .git is a directory (non-worktree clone).
|
||||
|
||||
[merge "ours"]
|
||||
driver = true
|
||||
@@ -12,7 +12,7 @@
|
||||
- 新增功能时考虑向后兼容性和 API 稳定性
|
||||
- 遵循项目的 Apache2.0 许可证要求
|
||||
- 遵循语义化版本控制规范
|
||||
- 新增异步任务时使用项目技能 `.agents/new-async-task/SKILL.md`
|
||||
- 新增异步任务时使用项目技能 `.agent/new-async-task/SKILL.md`
|
||||
|
||||
## 后端规范
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Build Image (openflare-agent)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish, for example v1.0.0-beta"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflare-agent
|
||||
DOCKERFILE: docker/Dockerfile.agent
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
|
||||
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
pattern: ${{ env.IMAGE_NAME }}-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
|
||||
FLOATING_TAG="beta"
|
||||
else
|
||||
FLOATING_TAG="latest"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
env:
|
||||
IMAGE: ${{ env.IMAGE }}
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}"
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Build Image (openflare-relay)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish, for example v1.0.0-beta"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflare-relay
|
||||
DOCKERFILE: docker/Dockerfile.relay
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflare-relay" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
|
||||
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflare-relay" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
pattern: ${{ env.IMAGE_NAME }}-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
|
||||
FLOATING_TAG="beta"
|
||||
else
|
||||
FLOATING_TAG="latest"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
env:
|
||||
IMAGE: ${{ env.IMAGE }}
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}"
|
||||
@@ -0,0 +1,270 @@
|
||||
name: Build Image (openflare)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary."
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
branches: ["canary"]
|
||||
|
||||
# One active run per ref (e.g. canary); newer runs cancel older in-progress builds.
|
||||
concurrency:
|
||||
group: build-image-openflare-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflare
|
||||
DOCKERFILE: docker/Dockerfile
|
||||
|
||||
jobs:
|
||||
# Resolve version / registries once. No checkout: triggers alone determine the tag.
|
||||
prepare:
|
||||
name: Prepare metadata
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.prep.outputs.version }}
|
||||
build_date: ${{ steps.prep.outputs.build_date }}
|
||||
image: ${{ steps.prep.outputs.image }}
|
||||
image_names: ${{ steps.prep.outputs.image_names }}
|
||||
images: ${{ steps.prep.outputs.images }}
|
||||
push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }}
|
||||
is_stable: ${{ steps.prep.outputs.is_stable }}
|
||||
is_prerelease: ${{ steps.prep.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Resolve version and images
|
||||
id: prep
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then
|
||||
VERSION="canary"
|
||||
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
VERSION="canary"
|
||||
else
|
||||
echo "unable to determine image version/tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$VERSION" == "canary" ]]; then
|
||||
IS_STABLE="false"
|
||||
IS_PRERELEASE="false"
|
||||
elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then
|
||||
IS_STABLE="false"
|
||||
IS_PRERELEASE="true"
|
||||
else
|
||||
IS_STABLE="true"
|
||||
IS_PRERELEASE="false"
|
||||
fi
|
||||
|
||||
IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}"
|
||||
IMAGE_NAMES="${IMAGE}"
|
||||
# Newline-separated list for docker/metadata-action
|
||||
IMAGES="${IMAGE}"
|
||||
|
||||
DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}"
|
||||
DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}"
|
||||
DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}"
|
||||
PUSH_DOCKERHUB="false"
|
||||
if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then
|
||||
HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}"
|
||||
HUB_NS="${HUB_NS,,}"
|
||||
IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}"
|
||||
IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}"
|
||||
IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}"
|
||||
PUSH_DOCKERHUB="true"
|
||||
echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}"
|
||||
else
|
||||
echo "Docker Hub secrets not set; publishing to GHCR only."
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=${VERSION}"
|
||||
echo "build_date=${BUILD_DATE}"
|
||||
echo "image=${IMAGE}"
|
||||
echo "image_names=${IMAGE_NAMES}"
|
||||
echo "push_dockerhub=${PUSH_DOCKERHUB}"
|
||||
echo "is_stable=${IS_STABLE}"
|
||||
echo "is_prerelease=${IS_PRERELEASE}"
|
||||
echo "images<<EOF"
|
||||
printf '%s\n' "${IMAGES}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}"
|
||||
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
# No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image.
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log into Docker Hub
|
||||
if: needs.prepare.outputs.push_dockerhub == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
BUILD_DATE=${{ needs.prepare.outputs.build_date }}
|
||||
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
|
||||
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ needs.prepare.outputs.image }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
steps:
|
||||
# No repo checkout: tags come from prepare + metadata-action.
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ needs.prepare.outputs.images }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.prepare.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }}
|
||||
type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
pattern: ${{ env.IMAGE_NAME }}-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log into Docker Hub
|
||||
if: needs.prepare.outputs.push_dockerhub == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ needs.prepare.outputs.image }}
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2046
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}"
|
||||
|
||||
- name: Trigger webhook
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -n "$WEBHOOK_URL" ]; then
|
||||
curl -fsSL "$WEBHOOK_URL"
|
||||
else
|
||||
echo "Webhook URL is not set, skipping."
|
||||
fi
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Build Image (openflared)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish, for example v1.0.0-beta"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflared
|
||||
DOCKERFILE: docker/Dockerfile.flared
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflared" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
|
||||
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflared" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
pattern: ${{ env.IMAGE_NAME }}-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
|
||||
FLOATING_TAG="beta"
|
||||
else
|
||||
FLOATING_TAG="latest"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
env:
|
||||
IMAGE: ${{ env.IMAGE }}
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}"
|
||||
@@ -1,270 +1,22 @@
|
||||
name: Build Image
|
||||
name: Build Image (Wavelet upstream — isolated)
|
||||
|
||||
# Isolated: OpenFlare publishes images via build-image-openflare.yml
|
||||
# (IMAGE_NAME: openflare). This Wavelet workflow is kept under the same
|
||||
# path so `git merge wavelet/main` cannot restore a canary wavelet image.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary."
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
branches: ["canary"]
|
||||
|
||||
# One active run per ref (e.g. canary); newer runs cancel older in-progress builds.
|
||||
concurrency:
|
||||
group: build-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: wavelet
|
||||
DOCKERFILE: docker/Dockerfile
|
||||
confirm:
|
||||
description: "Disabled on OpenFlare. Use build-image-openflare.yml."
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
# Resolve version / registries once. No checkout: triggers alone determine the tag.
|
||||
prepare:
|
||||
name: Prepare metadata
|
||||
isolated:
|
||||
name: Isolated
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.prep.outputs.version }}
|
||||
build_date: ${{ steps.prep.outputs.build_date }}
|
||||
image: ${{ steps.prep.outputs.image }}
|
||||
image_names: ${{ steps.prep.outputs.image_names }}
|
||||
images: ${{ steps.prep.outputs.images }}
|
||||
push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }}
|
||||
is_stable: ${{ steps.prep.outputs.is_stable }}
|
||||
is_prerelease: ${{ steps.prep.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Resolve version and images
|
||||
id: prep
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }}
|
||||
- name: Refuse Wavelet image publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then
|
||||
VERSION="canary"
|
||||
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
VERSION="canary"
|
||||
else
|
||||
echo "unable to determine image version/tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$VERSION" == "canary" ]]; then
|
||||
IS_STABLE="false"
|
||||
IS_PRERELEASE="false"
|
||||
elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then
|
||||
IS_STABLE="false"
|
||||
IS_PRERELEASE="true"
|
||||
else
|
||||
IS_STABLE="true"
|
||||
IS_PRERELEASE="false"
|
||||
fi
|
||||
|
||||
IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}"
|
||||
IMAGE_NAMES="${IMAGE}"
|
||||
# Newline-separated list for docker/metadata-action
|
||||
IMAGES="${IMAGE}"
|
||||
|
||||
DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}"
|
||||
DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}"
|
||||
DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}"
|
||||
PUSH_DOCKERHUB="false"
|
||||
if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then
|
||||
HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}"
|
||||
HUB_NS="${HUB_NS,,}"
|
||||
IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}"
|
||||
IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}"
|
||||
IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}"
|
||||
PUSH_DOCKERHUB="true"
|
||||
echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}"
|
||||
else
|
||||
echo "Docker Hub secrets not set; publishing to GHCR only."
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=${VERSION}"
|
||||
echo "build_date=${BUILD_DATE}"
|
||||
echo "image=${IMAGE}"
|
||||
echo "image_names=${IMAGE_NAMES}"
|
||||
echo "push_dockerhub=${PUSH_DOCKERHUB}"
|
||||
echo "is_stable=${IS_STABLE}"
|
||||
echo "is_prerelease=${IS_PRERELEASE}"
|
||||
echo "images<<EOF"
|
||||
printf '%s\n' "${IMAGES}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}"
|
||||
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
# No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image.
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log into Docker Hub
|
||||
if: needs.prepare.outputs.push_dockerhub == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
BUILD_DATE=${{ needs.prepare.outputs.build_date }}
|
||||
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
|
||||
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ needs.prepare.outputs.image }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
steps:
|
||||
# No repo checkout: tags come from prepare + metadata-action.
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ needs.prepare.outputs.images }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.prepare.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }}
|
||||
type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
pattern: ${{ env.IMAGE_NAME }}-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log into Docker Hub
|
||||
if: needs.prepare.outputs.push_dockerhub == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ needs.prepare.outputs.image }}
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2046
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}"
|
||||
|
||||
- name: Trigger webhook
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -n "$WEBHOOK_URL" ]; then
|
||||
curl -fsSL "$WEBHOOK_URL"
|
||||
else
|
||||
echo "Webhook URL is not set, skipping."
|
||||
fi
|
||||
echo "This Wavelet image workflow is isolated on OpenFlare."
|
||||
echo "Use .github/workflows/build-image-openflare.yml"
|
||||
exit 1
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
type: string
|
||||
|
||||
env:
|
||||
APP_NAME: wavelet
|
||||
APP_NAME: openflare-server
|
||||
GO_DIR: backend
|
||||
GO_MAIN: ./main.go
|
||||
GO_BUILD_TAGS: embed_frontend
|
||||
@@ -146,6 +146,7 @@ jobs:
|
||||
rm -rf "$EMBED_DIST_DIR"
|
||||
mkdir -p "$(dirname "$EMBED_DIST_DIR")"
|
||||
cp -R "$FRONTEND_OUT_DIR" "$EMBED_DIST_DIR"
|
||||
test -f "$EMBED_DIST_DIR/index.html"
|
||||
|
||||
- name: Upload embedded frontend
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -272,3 +273,148 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ needs.create-release.outputs.version }}
|
||||
files: ${{ steps.package.outputs.artifact }}
|
||||
|
||||
build-agent-binaries:
|
||||
name: Build agent ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-darwin-arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ${{ env.GO_DIR }}/go.mod
|
||||
|
||||
# GeoIP MMDB is not embedded; Docker images COPY mmdb files, bare binaries seed via download on first start.
|
||||
- name: Build Agent
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
cd backend
|
||||
go mod download
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/agent/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/agent/main.go
|
||||
|
||||
- name: Upload release artifact
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.create-release.outputs.version }}
|
||||
files: dist/${{ matrix.asset_name }}
|
||||
|
||||
build-relay-binaries:
|
||||
name: Build relay ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-relay-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-relay-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-relay-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-relay-darwin-arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ${{ env.GO_DIR }}/go.mod
|
||||
|
||||
- name: Build Relay
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
cd backend
|
||||
go mod download
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/relay/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/relay/main.go
|
||||
|
||||
- name: Upload release artifact
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.create-release.outputs.version }}
|
||||
files: dist/${{ matrix.asset_name }}
|
||||
|
||||
build-flared-binaries:
|
||||
name: Build flared ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflared-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflared-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflared-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflared-darwin-arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ${{ env.GO_DIR }}/go.mod
|
||||
|
||||
- name: Build Flared
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
run: |
|
||||
cd backend
|
||||
go mod download
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/flared/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/flared/main.go
|
||||
|
||||
- name: Upload release artifact
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.create-release.outputs.version }}
|
||||
files: dist/${{ matrix.asset_name }}
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Close Ticket
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
close_ticket:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 14
|
||||
stale-issue-message: "此 issue 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
|
||||
close-issue-message: "此 issue 因长期无活动已自动关闭,如有需要请重新开启"
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: 14
|
||||
stale-pr-message: "此 PR 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
|
||||
close-pr-message: "此 PR 因长期无活动已自动关闭,如有需要请重新开启"
|
||||
@@ -0,0 +1,48 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.10.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
go mod download
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Check PR Template Checklist
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
check-pr-template:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: check all checklist items are checked
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// get the pull request body
|
||||
const prBody = context.payload.pull_request.body || '';
|
||||
|
||||
// regex to match all checklist items in the template
|
||||
// matches lines like: - [ ] ... or - [x] ...
|
||||
const checklistRegex = /^- \[( |x|X)\] .+$/gm;
|
||||
const matches = prBody.match(checklistRegex) || [];
|
||||
|
||||
// check if any checklist item is not checked
|
||||
const unchecked = matches.filter(line => line.startsWith('- [ ]'));
|
||||
|
||||
// if any unchecked, fail the workflow
|
||||
if (unchecked.length > 0) {
|
||||
core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
|
||||
} else {
|
||||
console.log('all checklist items are checked.');
|
||||
}
|
||||
|
||||
+41
-14
@@ -12,6 +12,8 @@
|
||||
# config
|
||||
config.yaml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
@@ -27,8 +29,6 @@ frontend/.next/*
|
||||
frontend/next-env.d.ts
|
||||
frontend/package-lock.json
|
||||
frontend/.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
|
||||
# os
|
||||
@@ -46,24 +46,51 @@ go.work.sum
|
||||
main
|
||||
|
||||
# upload
|
||||
uploads/*
|
||||
|
||||
/uploads/
|
||||
s3_cache
|
||||
|
||||
/frontend/.next/
|
||||
/data/
|
||||
/internal/router/dist/
|
||||
/frontend/out/
|
||||
/.idea/
|
||||
/uploads/
|
||||
/*-source/
|
||||
/*-source.zip
|
||||
/.cache/
|
||||
/internal/router/root/dist/
|
||||
.dmux/
|
||||
|
||||
.worktrees/
|
||||
# test coverage
|
||||
*.out
|
||||
coverage.*
|
||||
*.coverprofile
|
||||
profile.cov
|
||||
|
||||
# generic ignores
|
||||
.cache
|
||||
.gocache*
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*-source
|
||||
*-source.zip
|
||||
.codex*
|
||||
.grok
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
# Server control-plane MaxMind Country seed (Country only; Agent does not embed)
|
||||
!internal/apps/openflare/geoip/data/GeoLite2-Country.mmdb
|
||||
|
||||
/.superpowers/
|
||||
/backend/plugins/domain/upload/filesrv/uploads/
|
||||
/backend/plugins/domain/upload/task/uploads/
|
||||
/backend/data/
|
||||
/backend/plugins/drivers/driver_http/dist/
|
||||
/.worktrees/
|
||||
/.pi-subagents/
|
||||
|
||||
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
|
||||
frontend/messages/zh-CN.json
|
||||
frontend/messages/en.json
|
||||
|
||||
# 上游 vendoring 目录内禁止出现运行期产物
|
||||
backend/plugins/**/uploads/
|
||||
backend/plugins/**/dist/
|
||||
backend/core/**/dist/
|
||||
backend/pkg/**/uploads/
|
||||
/backend/openflare/plugins/server/upload/filesrv/uploads/
|
||||
|
||||
+5
-11
@@ -25,17 +25,17 @@ linters:
|
||||
- gocritic # 各类代码问题
|
||||
- funlen # 函数过长
|
||||
|
||||
- gosec # 安全问题检查
|
||||
- gosec # 安全问题检查
|
||||
- bodyclose # HTTP response body 没有正确关闭
|
||||
- noctx # 没有传递 context.Context
|
||||
- contextcheck # 其他检查
|
||||
- sqlclosecheck # SQL rows 没有正确关闭
|
||||
- unconvert # 不必要的类型转换
|
||||
- nilerr # 函数返回 nil 错误
|
||||
- sqlclosecheck # SQL rows 没有正确关闭
|
||||
- unconvert # 不必要的类型转换
|
||||
- nilerr # 函数返回 nil 错误
|
||||
|
||||
settings:
|
||||
dupl:
|
||||
threshold: 80
|
||||
threshold: 120
|
||||
|
||||
cyclop:
|
||||
max-complexity: 20
|
||||
@@ -53,9 +53,3 @@ linters:
|
||||
- argument
|
||||
- condition
|
||||
- return
|
||||
|
||||
|
||||
# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实)
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -64,118 +64,186 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
每次完成一个功能点开发或修复一个问题后,务必提交 Git commit , 禁止推送远程仓库。
|
||||
遵循 Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support email login`)。
|
||||
|
||||
## 务必阅读匹配的 Skill
|
||||
## Skills(匹配任务时必读)
|
||||
|
||||
| Skill | 何时使用 |
|
||||
| :--- | :--- |
|
||||
| `new-api` | 基于 Cordis 插件开发业务 HTTP API、通过 `ctx.Router()` 声明路由与挂载中间件 |
|
||||
| `new-async-task` | 基于 Cordis 插件通过 `ctx.Task()` 与 `ctx.Schedule()` 注册 Asynq 异步任务与定时调度 |
|
||||
| `new-setting` | 基于 Cordis 插件通过 `ctx.Settings()` 声明配置 Schema、绑定 YAML 配置或管理台热加载设置 |
|
||||
| `database-migration` | 插件自包含 `embed.FS` 独立 Goose SQL 迁移(PG/SQLite 双方言、ClickHouse 分析库) |
|
||||
| `cache-framework` | 基于 `ctx.Cache()` 与 `contracts.CacheService` 访问三层缓存(RAM L1 + Redis L2 + Pub/Sub 同步) |
|
||||
| `logstore` | 日志/分析用途表、`internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
|
||||
| `clickhouse-batchwriter` | ClickHouse 批量写入、`internal/infra/persistence/batchwriter` 接入、分析表异步 flush 与背压策略 |
|
||||
| `file-upload` | 业务上传文件、Worker 程序化摄取、`upload.Ingest` / `contracts.StorageService`、文件访问与统计 |
|
||||
| `push-notification` | 系统通知推送事件、统一触发器投递、带消息推送的业务功能 |
|
||||
| `release-guide` | 根据自上一正式版本 Tag 以来的提交整理 Version Bump 提交信息以触发双语 Release |
|
||||
| `shadcn` | 添加、修改或组合 shadcn/ui 组件 |
|
||||
| `new-api` | 业务 API、Handler、服务层、路由注册 |
|
||||
| `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 |
|
||||
| `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` |
|
||||
| `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed |
|
||||
| `logstore` | 日志/分析用途表、`backend/internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
|
||||
| `clickhouse-batchwriter` | CH 批量写入、batchwriter、分析表 flush/背压 |
|
||||
| `file-upload` | 上传/摄取、`upload.Ingest`、文件访问、`w_uploads` |
|
||||
| `cache-framework` | 业务缓存(RAM/Redis/DB)、失效、多节点同步 |
|
||||
| `push-notification` | 通知推送事件、统一触发器、带推送的业务 |
|
||||
| `release-guide` | Version Bump 提交信息(触发双语 Release) |
|
||||
| `shadcn` | 添加/修改/组合 shadcn/ui 组件 |
|
||||
|
||||
## 严格遵循事项 (Guardrails)
|
||||
## 硬性约束
|
||||
|
||||
- 切勿删除 `frontend/node_modules`。
|
||||
- 保持 `backend/pkg/util/` 绝对纯净,禁止导入 Gin、GORM、sessions 等 Web/数据库框架包。
|
||||
- 测试用例禁止硬编码相对路径创建临时目录,统一使用 Go 内置 `t.TempDir()`。
|
||||
- 修改 API Handler 后运行 `make swagger`,完成代码开发后必须依次运行 `make code-check` 与 `make format`。
|
||||
### 上游/下游改动归属(Cordis)
|
||||
|
||||
### Cordis 架构核心防线与分层规范
|
||||
- **微内核 (`backend/core/`)**:
|
||||
- 上下文总线(`Context`)、泛型依赖注入(`Container`)、生命周期编排(`Lifecycle`)、扩展点定义(`extpoints/`)与领域事件总线(`EventBus`)。
|
||||
- **严禁**包含任何具体业务逻辑,**严禁** import `gin`、`gorm`、`asynq` 等具体运行时依赖。
|
||||
- **服务契约 (`backend/core/contracts/`)**:
|
||||
- 跨插件通信的统一公开 Go Interface(如 `AuthService`、`UserService`、`CacheService`、`DBService`、`StorageService`)与公共 DTO。
|
||||
- **严禁**包含任何具体业务实现或 SQL 操作。
|
||||
- **自包含插件 (`backend/plugins/`)**:
|
||||
- 所有业务功能与驱动实现均以插件形式存在(`backend/plugins/drivers/`、`backend/plugins/infra/`、`backend/plugins/domain/` 或下游 `backend/downstream/`)。
|
||||
- 每个插件实现 `core.Plugin`(`Name() string` 与 `Apply(ctx *core.Context) error`)。
|
||||
- **分层模式选型**:
|
||||
- **模式 1(极简单文件分层,微型插件)**:单 package 极简结构(仅单文件 `plugin.go`, `handlers.go`, `service.go`, `repository.go`, `models.go`, `errs.go`, `migrations/`)。
|
||||
- **模式 2(标准独立子包分层,推荐标准)**:多 package 物理隔离(`plugin.go`, `handler/`, `service/`, `repository/`, `model/`, `errs/`, `migrations/`)。**严禁在根包平铺 `handlers_*`、`service_*`、`repository_*` 等前缀文件**,子包内文件直接按业务命名(如 `user.go`, `config.go`),严格约束 `handler -> service -> repository -> model` 单向依赖。
|
||||
- **插件通信与依赖隔离**:
|
||||
- **严禁跨包 import internal/私有实现**:插件之间严禁直接 import 对方具体实现包代码。
|
||||
- **单向服务契约调用**:调用方仅面向 `backend/core/contracts` 编程,在 `Apply` 中通过 `core.Provide[contracts.XxxService](ctx, svc)` 注册服务,通过 `core.Inject[contracts.XxxService](ctx)` 或 `ctx.Using(func(svc contracts.XxxService) { ... })` 声明式解析。
|
||||
- **事件总线广播**:状态联动与解耦通信统一通过强类型事件 `ctx.Events().Emit()` 广播,由感兴趣的插件通过 `ctx.Events().On()` 订阅,消除双向依赖与循环引用。
|
||||
- **扩展点自包含注册**:
|
||||
- **HTTP 路由与白名单机制**:
|
||||
- 插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
|
||||
- **白名单机制**:`driver_http` 与微内核扩展点提供路由白名单支持(`ctx.Router().RegisterWhitelist(patterns...)`),支持精确路径与通配符(如 `/api/v1/oauth/*`)。
|
||||
- **所有权主动声明**:认证域(`auth` 插件)与各业务插件必须在 `Apply` 中主动注册其公开/免鉴权接口(如 `/api/v1/user/login`、`/api/v1/oauth/callback`、`/api/v1/cap/*` 等)。
|
||||
- **鉴权中间件放行防线**:`auth` 提供的登录鉴权中间件(`LoginRequired`)必须先执行白名单匹配并自动放行,彻底杜绝免鉴权接口被全局或组级鉴权中间件误拦截(返回 401 Unauthorized)。
|
||||
- **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。
|
||||
- **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins.<name>.*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。
|
||||
- **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。
|
||||
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
|
||||
- **表单一所有者原则 (Single Owner Principle)**:
|
||||
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
|
||||
- 严禁插件 B 跨过所有者插件 A 直接 DDL/DML 旁路读写表 A,必须调用插件 A 暴露的 `contracts` 接口或订阅事件。
|
||||
- **平台服务复用**:
|
||||
- 文件摄取统一使用 `upload.Ingest` / `contracts.StorageService`,禁止绕过存储域直接操作底层 Bucket 或直写文件表。
|
||||
- 业务缓存统一使用 `ctx.Cache()`(`contracts.CacheService`)或标准缓存框架,禁止自研不带失效广播的本地 map。
|
||||
- 数据库操作通过 `ctx.DB()`(`contracts.DBService`)获取受事务与 Trace 保护的连接。
|
||||
- 触碰框架目录 `backend/{core,pkg,plugins}` 前,先判断能力归属:
|
||||
- **通用能力**(与 OpenFlare 业务无关、任何下游都用得上)→ 必须同步在 **Wavelet 上游**完成修改,
|
||||
本仓库通过 `git fetch wavelet && git merge wavelet/main` 取得,不得长期持有本地补丁。
|
||||
- **非通用能力**(OpenFlare 业务特有)→ 在自己的插件内(`backend/openflare/plugins/<name>/`)实现,
|
||||
或新建一个下游插件,禁止塞进上游目录。
|
||||
- 开发下游功能优先**复用上游已有能力**(`core/contracts`、`backend/plugins/*`、`backend/pkg/*`);
|
||||
发现上游已提供而下游仍保留本地副本的,删除本地副本改为复用,或把差量回流上游。
|
||||
- 上游暂缺而确属通用能力时,可先在本仓库实现并登记到 `backend/openflare/upstream-patches.md`
|
||||
(merge 上游后请确认补丁仍在),回流 Wavelet 后删除登记并重新 merge。
|
||||
|
||||
## 后端开发规范
|
||||
- 禁止删除 `frontend/node_modules`。
|
||||
- `backend/pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `backend/openflare/plugins/server/oauth/session.go`)。
|
||||
- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。
|
||||
- HTTP 路由只由插件在 `Apply` 中经 `ctx.Router()` 声明;`router.BuildEngine()` 只挂引擎级中间件与前端 SPA 兜底,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。
|
||||
- API 变更后:`make swagger`;开发完成:`make code-check`;提交前:`make format`。
|
||||
- 缓存/文件管理复用平台实现,业务包禁止自建缓存目录或旁路存储后端。
|
||||
- 文件摄取走 `upload.Ingest`(`PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除走 `upload.Remove` / `upload.RemoveOwned`。禁止业务直接 `repository.CreateUpload` / `SoftDeleteUpload` 或 `db.Create(&model.Upload{})`。
|
||||
- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。
|
||||
- `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。
|
||||
- `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。
|
||||
- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `backend/openflare/plugins/server/kernel/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
|
||||
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `backend/openflare/plugins/server/platform/bootstrap` 在 `backend/cmd` 入口显式装配。
|
||||
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `backend/openflare/plugins/server/listener` 发域事件,push 在 bootstrap 订阅。
|
||||
- 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。
|
||||
- API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。
|
||||
|
||||
### API 响应规范
|
||||
- **统一信封**:`{ "error_msg": "", "data": ... }`
|
||||
- **成功**:HTTP 200,写出 `c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。
|
||||
- **失败**:使用 `backend/pkg/response` 的 `Abort*` 系列函数(如 `AbortBadRequest`、`AbortUnauthorized`、`AbortNotFound`、`AbortInternal`)中断请求。
|
||||
- **错误文案**:使用模块内 `errs.go` 中的 camelCase 字符串常量(如 `errBindParamsFailed`),禁止暴露底层数据库/系统错误细节给客户端。
|
||||
- **Service/Logics 分工**:业务逻辑层只接受 `context.Context`,返回 `(result, error)`,严禁依赖 `*gin.Context` 或调用 `c.JSON`/`Abort*`。
|
||||
- **错误日志**:底层错误在 Handler/Logic 边界用 `backend/pkg/logger` 打印日志,禁止使用 `_ = ...` 静默吞掉关键错误。
|
||||
### 文档与 Changelog
|
||||
|
||||
### 数据库操作
|
||||
- 插件数据库表结构严禁使用 GORM AutoMigrate,统一编写 Goose SQL 迁移并嵌入二进制。
|
||||
- 不创建物理外键(显式建索引);Go 模型零值需与数据库默认值匹配。
|
||||
- **SQL LIKE 查询防注入与转义**:所有含用户输入的模糊查询必须调用 `backend/pkg/util.EscapeLike` 转义通配符,并显式指定 `ESCAPE '\\'` 语法(如 `Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(keyword)+"%")`),同时兼容 PostgreSQL 与 SQLite 方言并杜绝通配符注入攻击。
|
||||
- 内容变更同步**中文文档**(不同步英文)。
|
||||
- 代码/配置变更写入 [`docs/changelog/index.md`](./docs/changelog/index.md) 的 `[Unreleased]`;纯文档变更不写 changelog。
|
||||
- Changelog:合并相近项;不记格式化/调试/无关重构;用户可读完整中文句;说明效果;不编造;不写密钥等敏感信息;空分类可省略。
|
||||
|
||||
### 并发与安全防护规范
|
||||
- **Goroutine 安全**:禁止直接使用裸 `go func()`;统一使用 `backend/pkg/util.Go`,确保具备未捕获 panic 恢复和调用栈日志记录能力。
|
||||
- **Pub/Sub 监听并发安全**:启动 Redis Pub/Sub 订阅监听前,必须捕获局部客户端实例,禁止在 goroutine 闭包中直读可变全局变量;提供停止监听接口时必须维护 `done` 通道等待 goroutine 完整退出后再重置状态,消除数据竞争。
|
||||
- **Session 固定攻击防御**:用户登录/授权成功后,必须调用 Session 轮换逻辑,防止 Session 固定攻击。
|
||||
- **防账户枚举与时序攻击**:
|
||||
- 登录失败统一返回模糊报错;当查询用户不存在时,必须调用 `pkg/util.DummyCheckPassword` 执行同等开销的 bcrypt 哈希计算,彻底消除时序侧信道攻击。
|
||||
- 验证码、签名 Token 等敏感字符串比对必须使用 `crypto/subtle.ConstantTimeCompare` 常量时间比对。
|
||||
- **敏感端点限流**:登录尝试、OAuth 授权发起等敏感接口必须接入基于 Redis 的滑动窗口限流机制,防止暴力破解与缓存资源耗尽。
|
||||
## 技术栈
|
||||
|
||||
## 前端开发规范
|
||||
- **后端**:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OTel、Zap、AWS SDK v2、Snowflake IDs
|
||||
- **前端**:Next.js App Router、TypeScript、Tailwind、pnpm、shadcn/ui
|
||||
|
||||
- 新特性开发前参考 Next.js 文档与 `frontend/app/(main)/admin/demo` 示例代码。
|
||||
- **页面容器与标题栏**:
|
||||
- 页面根容器统一使用全宽 `w-full`,最外层统一用 `py-6` 或 `py-6 px-1` 对齐边距。
|
||||
- 标题容器统一 `flex items-center gap-2`(带操作按钮用 `justify-between`)。
|
||||
- 图标直接使用 Lucide 组件(`size-5 text-primary`),禁止包裹背景小卡片或装饰边框。
|
||||
- 标题文字统一使用 `<h1 className="text-2xl font-semibold tracking-tight">`。
|
||||
- **无障碍语义与色彩规范 (a11y & WCAG)**:
|
||||
- **标题层级规范 (Heading Hierarchy)**:页面中非顶级结构化标题(如空状态提示、加载提示、卡片眉题/卡片标题、抽屉区块名)严禁滥用 `<h3>`/`<h4>`,统一使用 `<p>` 配合样式,保证屏幕阅读器感知的标题层级连续。
|
||||
- **无文本控件无障碍**:所有仅包含图标的按钮(如仅有 Icon 的 Button、Switch、无文本的 SelectTrigger)必须显式添加 `aria-label`。
|
||||
- **色彩对比度**:正文、提示、徽章等小字颜色在亮色/暗色模式下必须满足 WCAG AA(对比度 ≥ 4.5:1)。
|
||||
- **组件拆分与维护**:
|
||||
- 物理路由页面 `page.tsx` 仅维护高级骨架与布局。
|
||||
- 单文件超过 600 行或含多 Tab/大复杂区块时,必须按就近原则拆分为子组件存放在路由同级的 `components/` 局部目录中。
|
||||
- **样式与服务**:
|
||||
- 优先使用 shadcn/ui 的 `variant` 和全局 CSS 变量,不要在业务代码中硬编码颜色/背景。
|
||||
- 前端请求统一在 `frontend/lib/services/<name>/` 中继承 `BaseService` 编写并在 `index.ts` 注册。
|
||||
- **国际化 (i18n)**:
|
||||
- 使用 `next-intl`(**无 URL locale 前缀** / non-routing provider 模式),兼容 `NEXT_STANDALONE_EXPORT` 静态导出。
|
||||
- 支持语言:`zh-CN`、`en`;默认 `zh-CN`。
|
||||
- 解析优先级:cookie `NEXT_LOCALE`(用户显式选择)→ 浏览器语言 → 默认 `zh-CN`。
|
||||
- 文案统一放在 `frontend/messages/{locale}.json`,按命名空间嵌套(`common` / `layout` / `auth` / `settings` / 业务域)。
|
||||
- 组件内用户可见文案必须通过 `useTranslations()` / `getTranslations()` 读取;**禁止**新增中英硬编码 UI 字符串(后端返回的 `error_msg`、日志、调试信息除外)。
|
||||
- key 使用 camelCase 分层(如 `auth.login.submit`);完整短语作为 value,禁止在组件内拼接句子。
|
||||
- 新增或修改文案时必须**同步**更新 `zh-CN.json` 与 `en.json`,保持 key 树一致。
|
||||
- 语言选项展示用自称:`中文` / `English`(不随当前 UI 语言翻译)。
|
||||
- 日期/数字格式化使用 locale 感知 helper(如 `formatDateTime`),禁止写死 `'zh-CN'` / `date-fns` 的 `zhCN`。
|
||||
## Git
|
||||
|
||||
Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support email login`)。
|
||||
|
||||
---
|
||||
|
||||
## 后端
|
||||
|
||||
### 命名
|
||||
|
||||
| 类别 | 规则 | 例 |
|
||||
|------|------|-----|
|
||||
| 包/文件 | 小写蛇形 | `auth_source`、`postgres_logger.go` |
|
||||
| 导出/未导出标识符 | PascalCase / camelCase | — |
|
||||
| 请求/响应结构体 | camelCase + 后缀 | `listUsersRequest` |
|
||||
| 错误文案常量 | camelCase 字符串 `const`(非包级 `error`) | `errBindParamsFailed` |
|
||||
| YAML 键 | 小写蛇形 | — |
|
||||
|
||||
### Handler
|
||||
|
||||
- 命名:动词 + 名词(`ListUsers`);绑定用 `ShouldBindQuery` / `ShouldBindJSON`。
|
||||
- 每个 HTTP API 需完整 Swagger 注释;API 变更后 `make swagger`。
|
||||
- Handler:绑定 → 调 logic → 映射为 `Abort*` 或 `response.OK`。
|
||||
- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `backend/internal/apps/user/logics.go`。
|
||||
|
||||
### API 响应
|
||||
|
||||
信封:`{ "error_msg": "", "data": ... }`。成功 `error_msg` 空、`data` 为载荷;失败 `data` 为 `null`。分页:`data: { total, results }`。
|
||||
|
||||
**成功**(始终 HTTP 200):
|
||||
|
||||
```go
|
||||
c.JSON(http.StatusOK, response.OK(data))
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
```
|
||||
|
||||
**失败**:仅用 `response.Abort*`(挂 `c.Errors` 并 `Abort`,由 `ErrorHandlerMiddleware` 统一写出并记 OTel),阅读/internal/shared/response/abort.go使用已有函数
|
||||
|
||||
中间件同规则(`oauth.LoginRequired` → Unauthorized;`admin.LoginAdminRequired` → NotFound;`cap.VerifyMiddleware` → Unauthorized)。
|
||||
|
||||
- 用户可见错误:模块内 `errs.go` 的 camelCase 字符串常量;禁止向客户端暴露驱动错误/堆栈。
|
||||
- `response.Err` 仅供中间件构造 JSON,业务禁止用于 `c.JSON`。
|
||||
|
||||
**禁止**:`c.JSON(200, response.Err(...))`;Handler 直接 `c.JSON(4xx/5xx, response.Err(...))`;手写 `gin.H` 错误体;在 `logics.go` 里 `Abort*`。
|
||||
|
||||
Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort 状态声明 `@Failure`。
|
||||
|
||||
### 日志
|
||||
|
||||
- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `backend/pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
|
||||
- 吞错、转通用响应、worker 忽略前必须先记日志。
|
||||
- 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。
|
||||
- 只在处理/抑制边界记一次,避免重复刷日志。
|
||||
|
||||
### 路由与装配
|
||||
|
||||
- `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。
|
||||
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `backend/internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。
|
||||
- API/`all` 模式:`bootstrap.Init` 须在 `RegisterPushDomainEvents()` **之后**调用,保证 `SyncEvents` 同步内置推送元数据。
|
||||
|
||||
### 中间件
|
||||
|
||||
- 全局:`gin.Recovery()`、`otelgin`、日志、session。
|
||||
- 登录组:`oauth.LoginRequired()`;管理组:`admin.LoginAdminRequired()`。
|
||||
|
||||
### 配置
|
||||
|
||||
- 运行时只读 `config.Config`,禁止 `os.Getenv()`。
|
||||
- 新增配置同步 `config.example.yaml` 与 `backend/internal/infra/config/model.go`。
|
||||
|
||||
### 数据库
|
||||
|
||||
- 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。
|
||||
- repository 内用 `db.DB(ctx)`(链路追踪)。
|
||||
- 迁移:`backend/internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。
|
||||
- 不建物理外键,关系字段加显式索引。
|
||||
- 列默认值与 Go 零值(`nil`/`0`/`false`/`""`)一致。
|
||||
|
||||
---
|
||||
|
||||
## 前端
|
||||
|
||||
- Next.js:以 `node_modules/next/dist/docs/` 为准(训练数据可能过时)。
|
||||
- 示例:`frontend/app/(main)/admin/demo`。
|
||||
|
||||
### 样式
|
||||
|
||||
- shadcn 用 `variant` + CSS 变量;业务 `className` 不硬编码颜色/背景/阴影。
|
||||
- 变体不足时扩展组件 variant,不写一次性颜色。
|
||||
|
||||
### 页面结构
|
||||
|
||||
- 根容器全宽 `w-full`;禁止页面级 `max-w-*`(主布局负责宽度)。
|
||||
- 外层间距:`py-6` 或 `py-6 px-1`。
|
||||
- 标题行:`flex items-center gap-2`(有右侧操作则加 `justify-between`)。
|
||||
- 图标:Lucide 直接放标题容器,`size-5 text-primary`;禁止背景卡片/边框包裹。
|
||||
- 标题:仅 `h1 className="text-2xl font-semibold tracking-tight"`。
|
||||
- 多 Tab:各 Tab 独立文件;`page.tsx` 只管 Tabs 状态与触发器;禁止 `page.tsx` 仅转发同名空壳。
|
||||
- 单文件 > ~600 行或状态过重时拆局部 `components/`;跨页复用放 `frontend/components/common/`。标杆:`/admin/database`。
|
||||
|
||||
### 组件放置
|
||||
|
||||
| 类型 | 路径 |
|
||||
|------|------|
|
||||
| 跨页业务 | `frontend/components/common/` |
|
||||
| shadcn 原语 | `frontend/components/ui/` |
|
||||
| 路由专属 | 邻近 feature 目录 |
|
||||
|
||||
### Services
|
||||
|
||||
```text
|
||||
frontend/lib/services/<name>/
|
||||
types.ts
|
||||
<name>.service.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
- 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。
|
||||
- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。
|
||||
- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。
|
||||
- 文案放在 `frontend/messages/fragments`。参考已有代码,按模块拆文件夹,en.json 和 zh-CN.json 是 ci 生成的(node scripts/merge-i18n-fragments.mjs),禁止手动修改。
|
||||
- 禁止在页面/组件里直接写文案,文案必须支持 i18
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢您有兴趣为本项目做出贡献!我们欢迎各种形式的贡献,但请先阅读如下文档,以节省您和我们的时间。
|
||||
|
||||
当您在使用 Claude Code, Gemini CLI 等 Vibe-coding 工具时,推荐将此文档内容附加到上下文内。
|
||||
|
||||
## 我们不接受的更改
|
||||
|
||||
出于包括但不限于项目可持续性与可维护性考虑,我们不接受如下类型的更改。
|
||||
如果您提交的 PR 包含以下类型的更改,我们可能会包括但不限于忽略、关闭或要求您更改 PR 内容。
|
||||
|
||||
- 导致项目整体性能下降的更改;
|
||||
- 仅修改注释、空格、格式的小 PR;
|
||||
- 仅修正无影响力的拼写错误(typo)或代码注释,不提升可读性或准确性;
|
||||
- 重构已稳定工作的逻辑而不带来可维护性或功能上的实质提升;
|
||||
- 未经讨论的接口或 API 命名改动;
|
||||
|
||||
**请注意:判断标准不是改动大小,而是改动是否有实际作用。**
|
||||
|
||||
为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。
|
||||
|
||||
|
||||
## 合并上游
|
||||
|
||||
`.gitattributes` 对 `backend/openflare/`、`frontend/` 等路径使用 `merge=ours`。该驱动不会自动生效,请在仓库根目录执行一次:
|
||||
|
||||
```bash
|
||||
git config include.path ../.gitconfig
|
||||
# worktree 安全写法:
|
||||
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
```
|
||||
|
||||
## 贡献步骤
|
||||
|
||||
1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。
|
||||
2. **编写代码**,确保遵循项目的代码风格和最佳实践。
|
||||
3. **添加/更新测试**,确保您的更改不会破坏现有功能。
|
||||
4. **本地测试**,确认所有测试通过。
|
||||
5. **提交 Pull Request**,请详细描述您的更改内容和动机。
|
||||
|
||||
|
||||
## 代码规范
|
||||
|
||||
### 后端
|
||||
|
||||
**基础检查**
|
||||
|
||||
需要通过 CodeQL 扫描,较长的代码建议增加 Copilot 检查。
|
||||
|
||||
**API 文档**
|
||||
|
||||
所有接口需要写 Swagger 文档,提交前通过 make swagger 更新文档后再提交。
|
||||
|
||||
**响应格式**
|
||||
|
||||
```json
|
||||
# 响应数据最外层有两个字段,error_msg 和 data
|
||||
{
|
||||
"error_msg": "",
|
||||
"data": null
|
||||
}
|
||||
|
||||
# 如果是非列表数据
|
||||
{
|
||||
"error_msg": "",
|
||||
"data": {}
|
||||
}
|
||||
|
||||
# 如果是分页数据
|
||||
{
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"total": 0,
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**数据库**
|
||||
|
||||
- 禁止使用外键,但需要保留对应字段的索引;
|
||||
- 字段如有默认值,需要与 struct 默认值相同,如 nil,0,false,空字符串等,避免初始化时未填写或漏填写导致的数据异常。
|
||||
|
||||
### 前端
|
||||
|
||||
**基础检查**
|
||||
|
||||
代码需要通过 ESLint 检查和 CodeQL 扫描。
|
||||
|
||||
**类型安全**
|
||||
|
||||
- 禁止使用 `any` 类型,`any` 类型绕过了 TypeScript 的类型检查系统,会导致潜在的运行时错误;
|
||||
- `unknown` 是类型安全的 `any`,但必须立即进行类型断言或类型收窄;
|
||||
- `never` 类型表示永远不会发生的值类型,必须谨慎使用,并提供清晰的注释说明。
|
||||
|
||||
**组件规范**
|
||||
|
||||
- 组件应按功能分类
|
||||
- 公共组件放在 `components/common` 目录
|
||||
- ShadcnUI 组件放在 `components/ui` 目录
|
||||
- 自定义图标应放置在 `/components/icons/` 目录下以命名导出形式管理,对于常规的图标,我们使用 Lucide 库
|
||||
|
||||
**服务层**
|
||||
|
||||
服务层架构是前端与API交互的统一入口,基于以下原则:
|
||||
1. 关注点分离 - 每个服务负责一个业务领域
|
||||
2. 统一入口 - 通过services对象导出所有服务
|
||||
3. 类型安全 - 所有请求和响应有明确类型定义
|
||||
|
||||
|
||||
**如何新建接口服务**
|
||||
|
||||
1. **创建目录结构**:
|
||||
```
|
||||
/services/新服务名/
|
||||
- types.ts // 类型定义
|
||||
- 服务名.service.ts // 服务实现
|
||||
- index.ts // 导出服务
|
||||
```
|
||||
|
||||
2. **实现服务类**:
|
||||
```typescript
|
||||
// 新服务名/服务名.service.ts
|
||||
import {BaseService} from '../core/base.service';
|
||||
|
||||
export class 新服务类 extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/路径';
|
||||
|
||||
static async 方法名(参数): Promise<返回类型> {
|
||||
return this.get<返回类型>('/endpoint');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **在services/index.ts注册**:
|
||||
```typescript
|
||||
import {新服务类} from './新服务名';
|
||||
|
||||
const services = {
|
||||
auth: AuthService,
|
||||
新服务名: 新服务类
|
||||
};
|
||||
```
|
||||
|
||||
**使用方法**
|
||||
|
||||
```typescript
|
||||
import services from '@/lib/services';
|
||||
|
||||
// 调用服务方法
|
||||
const 结果 = await services.新服务名.方法名(参数);
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: swagger license license-check build-embedded build-test cross-build code-check format canary
|
||||
.PHONY: swagger license license-check format build-embedded build-test cross-build code-check build-backend build-frontend build-agent build-relay build-flared build-all
|
||||
|
||||
VERSION ?= dev
|
||||
BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
@@ -14,9 +14,13 @@ license-check:
|
||||
scripts/update_go_license.sh --check
|
||||
|
||||
format:
|
||||
@echo "==> Formatting backend Go source with golangci-lint fmt (gofumpt, same gate as code-check)..."
|
||||
cd backend && golangci-lint fmt
|
||||
@echo "==> Formatting frontend source..."
|
||||
@echo "==> Formatting backend Go source and removing unused imports..."
|
||||
@command -v goimports >/dev/null 2>&1 || { \
|
||||
echo "goimports not found, installing..."; \
|
||||
go install golang.org/x/tools/cmd/goimports@latest; \
|
||||
}
|
||||
goimports -w $$(find backend -type f -name '*.go')
|
||||
@echo "==> Formatting frontend source and removing unused imports..."
|
||||
cd frontend && pnpm format
|
||||
|
||||
build-embedded:
|
||||
@@ -31,21 +35,49 @@ build-embedded:
|
||||
cd backend && go build \
|
||||
-tags embed_frontend \
|
||||
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o ../bin/wavelet \
|
||||
-o ../bin/openflare-server \
|
||||
main.go
|
||||
|
||||
code-check:
|
||||
@scripts/check_cordis_architecture.sh
|
||||
@echo "==> Architecture guards..."
|
||||
@command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; }
|
||||
@if rg -n 'db\.DB\(|db\.Redis' backend/openflare/plugins/server/kernel/model --glob '*.go' -g '!*_test.go' ; then \
|
||||
echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd backend && golangci-lint run
|
||||
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
|
||||
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
|
||||
|
||||
build-backend:
|
||||
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
|
||||
-o ../bin/wavelet \
|
||||
-o ../bin/openflare-server \
|
||||
main.go
|
||||
|
||||
build-agent:
|
||||
@echo "==> Building agent version=$(VERSION)..."
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/agent/config.Version=$(VERSION)'" \
|
||||
-o ../bin/openflare-agent \
|
||||
cmd/agent/main.go
|
||||
|
||||
build-relay:
|
||||
@echo "==> Building relay version=$(VERSION)..."
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/relay/config.Version=$(VERSION)'" \
|
||||
-o ../bin/openflare-relay \
|
||||
cmd/relay/main.go
|
||||
|
||||
build-flared:
|
||||
@echo "==> Building flared version=$(VERSION)..."
|
||||
cd backend && go build \
|
||||
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/flared/config.Version=$(VERSION)'" \
|
||||
-o ../bin/flared \
|
||||
cmd/flared/main.go
|
||||
|
||||
build-all: build-backend build-agent build-relay build-flared
|
||||
|
||||
build-frontend:
|
||||
@echo "==> Building frontend version=$(VERSION) build_date=$(BUILD_DATE)..."
|
||||
cd frontend && \
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
OpenFlare
|
||||
|
||||
This product includes software derived from Wavelet.
|
||||
|
||||
Wavelet:
|
||||
Copyright 2025 Arctel.net
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
|
||||
This distribution includes modifications by Arctel.net.
|
||||
@@ -1,352 +1,192 @@
|
||||
# wavelet
|
||||
<div align="center">
|
||||
|
||||
🚀 A modern, production-ready full-stack boilerplate for building scalable web applications
|
||||
# OpenFlare
|
||||
|
||||
[中文](./README_zh.md)
|
||||
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://golang.org/)
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges.
|
||||
|
||||
## 📖 Introduction
|
||||
</div>
|
||||
|
||||
**wavelet** is a generic, production-ready full-stack boilerplate built with **Go (Gin + GORM)** on the backend and **Next.js (App Router + Shadcn UI)** on the frontend. It ships with everything you need to bootstrap a modern SaaS, internal tool, or developer platform — without the boilerplate headaches.
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
|
||||
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
The project was designed from the ground up to be **framework-first and business-agnostic**: plug in your own domain logic while reusing the battle-tested infrastructure that comes out of the box.
|
||||
> [!WARNING]
|
||||
> After the first login with the `admin` user, you must change the default password `12345678`.
|
||||
>
|
||||
> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments.
|
||||
|
||||
### ✨ Key Features
|
||||
## Documentation
|
||||
|
||||
- 🔐 **Multi-auth System** — Local password login/registration + pluggable OIDC/OAuth2 providers (supports multiple auth sources simultaneously)
|
||||
- 🗝️ **Personal Access Tokens** — API key management for programmatic access; supports `Authorization: Bearer` and `X-Access-Token` headers
|
||||
- 👤 **User Management** — Admin panel for listing, searching, filtering, enabling/disabling user accounts
|
||||
- ⚙️ **Dynamic System Config** — Key-value system configuration management with live reload, controllable from the admin UI
|
||||
- 📋 **Async Task Queue** — Background job processing with [Asynq](https://github.com/hibiken/asynq) (Redis-backed), including a scheduling dashboard
|
||||
- 📁 **S3 File Storage** — Unified file upload/download via S3-compatible APIs with local disk cache
|
||||
- 📊 **Observability** — Structured logging (Zap) + distributed tracing (OpenTelemetry)
|
||||
- 🎨 **Modern UI** — Responsive, dark-mode-ready design system built with Tailwind CSS 4 and Shadcn UI
|
||||
- 📖 **Built-in Documentation** — Integrated docs portal with usage guides, API reference, privacy policy, and terms of service
|
||||
**https://openflare.fyrn.link**
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
Common entry points:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Database │
|
||||
│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │
|
||||
│ │ │ │ │ │
|
||||
│ • React 19 │ │ • Gin HTTP Framework │ │ • PostgreSQL │
|
||||
│ • TypeScript │ │ • GORM ORM │ │ • Redis Cache │
|
||||
│ • Tailwind 4 │ │ • Multi-provider Auth │ │ │
|
||||
│ • Shadcn UI │ │ • AccessToken Middleware │ │ │
|
||||
│ │ │ • Asynq Task Queue │ │ │
|
||||
│ │ │ • OpenTelemetry Tracing │ │ │
|
||||
│ │ │ • Swagger API Docs │ │ │
|
||||
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ Multi-Process CLI │
|
||||
│ (Cobra + Viper) │
|
||||
│ • api (HTTP) │
|
||||
│ • worker (Queue) │
|
||||
│ • scheduler(Cron) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
|
||||
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
|
||||
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
|
||||
* [System Design](https://openflare.fyrn.link/design/)
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
## Core Capabilities
|
||||
|
||||
### Backend
|
||||
- **[Go 1.25+](https://go.dev/doc)** — Primary language
|
||||
- **[Gin](https://github.com/gin-gonic/gin)** — HTTP web framework
|
||||
- **[GORM](https://github.com/go-gorm/gorm)** — ORM with PostgreSQL & ClickHouse support
|
||||
- **[Redis](https://github.com/redis/redis)** — Cache, session store, and task queue backend
|
||||
- **[Asynq](https://github.com/hibiken/asynq)** — Distributed task queue (Redis-backed)
|
||||
- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI entrypoint and configuration management
|
||||
- **[OpenTelemetry](https://opentelemetry.io)** — Distributed tracing and observability
|
||||
- **[Zap](https://github.com/uber-go/zap)** — Structured, high-performance logging
|
||||
- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — Auto-generated API documentation
|
||||
- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3-compatible file storage
|
||||
- **[Snowflake](https://github.com/bwmarrin/snowflake)** — Distributed ID generation
|
||||
* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes.
|
||||
* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients.
|
||||
* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses.
|
||||
* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge.
|
||||
* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy.
|
||||
* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol.
|
||||
* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status.
|
||||
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login.
|
||||
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers.
|
||||
|
||||
### Frontend
|
||||
- **[Next.js 16](https://github.com/vercel/next.js)** — React framework with App Router
|
||||
- **[React 19](https://github.com/facebook/react)** — UI library
|
||||
- **[TypeScript](https://github.com/microsoft/TypeScript)** — Type safety
|
||||
- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — Utility-first styling
|
||||
- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — Accessible, composable component library
|
||||
- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — Icon library
|
||||
## Interface Preview
|
||||
|
||||
## 📋 Requirements
|
||||
### Dashboard Overview
|
||||
|
||||
- **Go** >= 1.25
|
||||
- **Node.js** >= 18.0
|
||||
- **PostgreSQL** >= 14
|
||||
- **Redis** >= 6.0
|
||||
- **pnpm** >= 8.0 (recommended)
|
||||

|
||||
|
||||
## 🚀 Quick Start
|
||||
### Access Logs
|
||||
|
||||
### 1. Clone the Repository
|
||||

|
||||
|
||||
### WAF Protection
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
### Hardware Configuration Recommendations
|
||||
|
||||
| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes |
|
||||
|------------------------|-----------------------------------|-----------------------------------|-------|
|
||||
| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic |
|
||||
| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing |
|
||||
| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput |
|
||||
| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed |
|
||||
|
||||
### 1. Start the Server
|
||||
|
||||
Use `docker-compose`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Rain-kl/Wavelet.git refreshing
|
||||
cd refreshing
|
||||
# Download environment variable template and create .env file
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
```
|
||||
|
||||
See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details.
|
||||
|
||||
Access address: `http://localhost:3000`
|
||||
|
||||
Default account:
|
||||
|
||||
* Username: `admin`
|
||||
* Password: `12345678`
|
||||
|
||||
### 2. Install Agent
|
||||
|
||||
Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image.
|
||||
|
||||
You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below:
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
Docker deployment can directly run the Agent image:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-v openflare-agent-pages:/data/var/lib/openflare/pages \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
Edit `config.yaml` to configure your database and Redis. OIDC auth sources are configured at runtime in the admin settings page.
|
||||
## Cordis / Wavelet upstream
|
||||
|
||||
### 3. Initialize Database
|
||||
OpenFlare is built on Wavelet Cordis. After cloning, enable `merge=ours` from `.gitattributes` so `git merge wavelet/main` keeps OpenFlare-owned paths:
|
||||
|
||||
```bash
|
||||
# Start local dependencies (PostgreSQL + Redis)
|
||||
docker compose up -d
|
||||
|
||||
# Optional: also start ClickHouse
|
||||
docker compose --profile clickhouse up -d
|
||||
|
||||
# If you use an external PostgreSQL instance instead of Docker, create the database manually
|
||||
createdb -h <host> -p 5432 -U postgres refreshing
|
||||
|
||||
# Database schema is auto-migrated on first startup
|
||||
git config include.path ../.gitconfig
|
||||
# worktree-safe:
|
||||
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
```
|
||||
|
||||
### 4. Start the Backend
|
||||
`docker compose` uses `docker-compose.yaml`. `docker-compose.wavelet.yml` is the upstream Wavelet stack and is not the product default. Image publishes go through `.github/workflows/build-image-openflare*.yml`; the Wavelet `build-image.yml` is isolated.
|
||||
|
||||
```bash
|
||||
# Install Go dependencies
|
||||
go mod tidy
|
||||
## Open Source License
|
||||
|
||||
# Generate Swagger API documentation
|
||||
make swagger
|
||||
This project is licensed under the [Apache License 2.0](./LICENSE).
|
||||
|
||||
# Start the HTTP API server
|
||||
go run main.go api
|
||||
```
|
||||
## Star History
|
||||
|
||||
> The backend also supports separate `scheduler` and `worker` processes for async task processing:
|
||||
> ```bash
|
||||
> go run main.go scheduler # Cron job scheduler
|
||||
> go run main.go worker # Asynq task worker
|
||||
> ```
|
||||
|
||||
### 5. Start the Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start dev server (Turbopack)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 6. Access the Application
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Frontend | http://localhost:3000 |
|
||||
| Swagger API Docs | http://localhost:8000/swagger/index.html |
|
||||
| Health Check | http://localhost:8000/api/health |
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Key configuration options (see `config.example.yaml` for the full reference):
|
||||
|
||||
| Option | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `app.addr` | Backend listen address | `:8000` |
|
||||
| `database.host` | PostgreSQL host | `127.0.0.1` |
|
||||
| `database.database` | Database name | `refreshing` |
|
||||
| `redis.host` | Redis host | `127.0.0.1` |
|
||||
| `storage.endpoint` | S3-compatible endpoint | `s3.amazonaws.com` |
|
||||
|
||||
## 🔧 Development Guide
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Run API server
|
||||
go run main.go api
|
||||
|
||||
# Run task scheduler
|
||||
go run main.go scheduler
|
||||
|
||||
# Run async worker
|
||||
go run main.go worker
|
||||
|
||||
# Regenerate Swagger docs (required after controller changes)
|
||||
make swagger
|
||||
|
||||
# Format & vet code
|
||||
make tidy
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Development mode (Turbopack)
|
||||
pnpm dev
|
||||
|
||||
# Production build
|
||||
pnpm build
|
||||
|
||||
# Start production server
|
||||
pnpm start
|
||||
|
||||
# Lint & format
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
wavelet/
|
||||
├── main.go # Entry point (delegates to internal/cmd)
|
||||
├── config.example.yaml # Configuration template
|
||||
├── Makefile # Common commands (swagger, tidy, license, cross-build)
|
||||
├── docker/ # Docker image build files (integrated/frontend/backend)
|
||||
├── docs/ # Swagger auto-generated docs
|
||||
├── frontend/ # Next.js frontend application
|
||||
│ ├── app/ # App Router pages
|
||||
│ ├── components/ # React components (ui, common, layout)
|
||||
│ ├── lib/services/ # API service layer
|
||||
│ └── types/ # TypeScript type definitions
|
||||
└── internal/ # Go backend (private)
|
||||
├── cmd/ # CLI commands (api, scheduler, worker)
|
||||
├── apps/ # Business modules (oauth, user, admin, upload)
|
||||
├── model/ # GORM entities and business methods
|
||||
├── router/ # HTTP route registration
|
||||
├── task/ # Async task definitions and workers
|
||||
├── db/ # Database and Redis initialization
|
||||
├── storage/ # S3 file storage abstraction
|
||||
└── common/ # Shared utilities and response helpers
|
||||
```
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
Swagger API documentation is auto-generated and available once the backend is running:
|
||||
|
||||
```
|
||||
http://localhost:8000/swagger/index.html
|
||||
```
|
||||
|
||||
The built-in frontend docs portal at `/docs` includes:
|
||||
- **Usage Guide** — Step-by-step walkthrough for getting started
|
||||
- **API Reference** — Detailed interface documentation
|
||||
- **Privacy Policy** — Template privacy policy (customize as needed)
|
||||
- **Terms of Service** — Template terms of service
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
go test ./...
|
||||
|
||||
# Frontend lint
|
||||
cd frontend && pnpm lint
|
||||
```
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Cross-platform Binary
|
||||
|
||||
Build static binaries for all 6 targets (Linux / macOS / Windows × amd64 / arm64) with a single command.
|
||||
The compiled frontend is embedded in every binary — no separate deployment needed.
|
||||
|
||||
**Prerequisites:** Docker with BuildKit enabled (Docker 23+ defaults to on).
|
||||
|
||||
```bash
|
||||
# Build all 6 binaries → ./bin/
|
||||
make cross-build
|
||||
|
||||
# Stamp a release version
|
||||
make cross-build VERSION=v1.2.3
|
||||
|
||||
# Build only a specific OS (both architectures)
|
||||
make cross-build GOOS=linux
|
||||
make cross-build GOOS=darwin
|
||||
make cross-build GOOS=windows
|
||||
|
||||
# Build only a specific architecture (all OSes)
|
||||
make cross-build GOARCH=amd64
|
||||
make cross-build GOARCH=arm64
|
||||
|
||||
# Combine filters — single binary
|
||||
make cross-build GOOS=linux GOARCH=arm64
|
||||
make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3
|
||||
```
|
||||
|
||||
Output files in `./bin/`:
|
||||
|
||||
| File | Platform |
|
||||
|------|----------|
|
||||
| `wavelet_linux_amd64` | Linux x86-64 |
|
||||
| `wavelet_linux_arm64` | Linux ARM64 |
|
||||
| `wavelet_darwin_amd64` | macOS Intel |
|
||||
| `wavelet_darwin_arm64` | macOS Apple Silicon |
|
||||
| `wavelet_windows_amd64.exe` | Windows x86-64 |
|
||||
| `wavelet_windows_arm64.exe` | Windows ARM64 |
|
||||
|
||||
> The version string is accessible at runtime via `wavelet --version`.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t refreshing .
|
||||
|
||||
# Run (pass your config as a volume mount)
|
||||
docker run -d -p 8000:8000 \
|
||||
-v $(pwd)/config.yaml:/app/config.yaml \
|
||||
refreshing api
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
1. Build the frontend:
|
||||
```bash
|
||||
cd frontend && pnpm build
|
||||
```
|
||||
|
||||
2. Compile the backend:
|
||||
```bash
|
||||
go build -o refreshing main.go
|
||||
```
|
||||
|
||||
3. Configure `config.yaml` for production.
|
||||
|
||||
4. Start services:
|
||||
```bash
|
||||
./refreshing api # HTTP API
|
||||
./refreshing scheduler # Cron scheduler (optional)
|
||||
./refreshing worker # Task worker (optional)
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please read the following before submitting code:
|
||||
|
||||
- [Contributing Guidelines](CONTRIBUTING.md)
|
||||
- [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
- [Contributor License Agreement](CLA.md)
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add your feature'`)
|
||||
4. Push to the branch (`git push origin feature/your-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [Apache 2.0 License](LICENSE).
|
||||
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
<div align="center">
|
||||
|
||||
# OpenFlare
|
||||
|
||||
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
|
||||
|
||||
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
|
||||
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
|
||||
>
|
||||
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
|
||||
|
||||
## 文档
|
||||
|
||||
**https://openflare.fyrn.link**
|
||||
|
||||
常用入口:
|
||||
|
||||
* [快速开始](https://openflare.fyrn.link/guide/quick-start)
|
||||
* [部署说明](https://openflare.fyrn.link/deployment/deployment)
|
||||
* [配置项参考](https://openflare.fyrn.link/reference/configuration)
|
||||
* [系统设计](https://openflare.fyrn.link/design/)
|
||||
|
||||
## 核心能力
|
||||
|
||||
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
|
||||
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
|
||||
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
|
||||
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
|
||||
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
|
||||
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
|
||||
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
|
||||
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
|
||||
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||

|
||||
|
||||
### 访问日志
|
||||
|
||||

|
||||
|
||||
### WAF 防护
|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
|
||||
### 硬件配置推荐
|
||||
|
||||
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
|
||||
| --- |-------------------------------| --- | --- |
|
||||
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
|
||||
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
|
||||
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
|
||||
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
|
||||
|
||||
### 1. 启动 Server
|
||||
|
||||
使用 docker-compose
|
||||
|
||||
```bash
|
||||
# 下载环境变量模板并创建 .env 文件
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
```
|
||||
|
||||
详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。
|
||||
|
||||
访问地址:`http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
|
||||
* 用户名:`admin`
|
||||
* 密码:`12345678`
|
||||
|
||||
### 2. 安装 Agent
|
||||
|
||||
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
|
||||
|
||||
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
|
||||
|
||||
#### Docker 部署
|
||||
|
||||
Docker 部署可直接运行 Agent 镜像:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-v openflare-agent-pages:/data/var/lib/openflare/pages \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Cordis / Wavelet 上游
|
||||
|
||||
OpenFlare 构建在 Wavelet Cordis 之上。克隆后请启用 `.gitattributes` 中的 `merge=ours`,这样 `git merge wavelet/main` 会保留 OpenFlare 自有路径:
|
||||
|
||||
```bash
|
||||
git config include.path ../.gitconfig
|
||||
# worktree 安全写法:
|
||||
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
|
||||
```
|
||||
|
||||
`docker compose` 使用 `docker-compose.yaml`。`docker-compose.wavelet.yml` 是上游 Wavelet 编排,不是本产品的默认栈。镜像发布走 `.github/workflows/build-image-openflare*.yml`;Wavelet 的 `build-image.yml` 已隔离。
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
+1
-1
@@ -152,7 +152,7 @@ pnpm dev
|
||||
|------|------|
|
||||
| 前端界面 | http://localhost:3000 |
|
||||
| Swagger 接口文档 | http://localhost:8000/swagger/index.html |
|
||||
| 健康检查 | http://localhost:8000/api/health |
|
||||
| 健康检查 | http://localhost:8000/api/healthz |
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command agent runs the OpenFlare edge agent daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
agentplugin "Wavelet/openflare/plugins/agent"
|
||||
"Wavelet/openflare/plugins/agent/logging"
|
||||
)
|
||||
|
||||
// shutdownTimeout 为 openresty 收敛与在途配置同步预留的退出窗口。
|
||||
const shutdownTimeout = 60 * time.Second
|
||||
|
||||
func main() {
|
||||
logging.Setup()
|
||||
|
||||
configPath := flag.String("config", "./agent.json", "agent config path")
|
||||
flag.Parse()
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.Profile(agentplugin.DriverTypeAgent)),
|
||||
core.WithShutdownTimeout(shutdownTimeout),
|
||||
)
|
||||
app.Use(agentplugin.New(*configPath))
|
||||
|
||||
if err := app.Prepare(); err != nil {
|
||||
slog.Error("agent startup failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
slog.Error("agent process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -6,6 +6,8 @@ package cmd
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
ofserver "Wavelet/openflare/plugins/server"
|
||||
"Wavelet/openflare/plugins/server/migrate"
|
||||
"Wavelet/plugins/domain/admin"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/domain/cap"
|
||||
@@ -49,7 +51,7 @@ const (
|
||||
|
||||
// runProfileApp prepares and runs the application for a given profile.
|
||||
func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) {
|
||||
app := newWaveletApp(profile)
|
||||
app := newOpenFlareApp(profile)
|
||||
if err := app.Prepare(); err != nil {
|
||||
log.Fatalf("[%s] prepare failed: %v\n", mode, err)
|
||||
}
|
||||
@@ -67,8 +69,8 @@ func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers.
|
||||
func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
||||
// newOpenFlareApp creates a core.App wired with Wavelet platform plugins plus the OpenFlare server plugin.
|
||||
func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
||||
src, err := config.NewSource()
|
||||
if err != nil {
|
||||
log.Fatalf("[App] load config source failed: %v\n", err)
|
||||
@@ -78,6 +80,7 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
||||
core.WithProfile(profile),
|
||||
core.WithConfigSource(src),
|
||||
core.WithShutdownTimeout(defaultShutdownTimeout),
|
||||
core.WithMigrationBaseline(migrate.Legacy),
|
||||
}
|
||||
appOpts = append(appOpts, opts...)
|
||||
|
||||
@@ -112,10 +115,15 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
||||
system.New(),
|
||||
)
|
||||
|
||||
// 4. Bind Goose migration engine
|
||||
// 4. OpenFlare business routes (after domain plugins, before the HTTP driver)
|
||||
app.Use(
|
||||
ofserver.New(),
|
||||
)
|
||||
|
||||
// 5. Bind Goose migration engine
|
||||
app.SetMigrationEngine(&gooseEngine{})
|
||||
|
||||
// 5. Mount HTTP runtime driver
|
||||
// 6. Mount HTTP runtime driver
|
||||
app.Use(
|
||||
driver_http.New(),
|
||||
)
|
||||
|
||||
+121
-139
@@ -4,148 +4,130 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"Wavelet/core"
|
||||
)
|
||||
|
||||
func TestNewWaveletAppProfiles(t *testing.T) {
|
||||
profiles := []core.Profile{
|
||||
core.ProfileAPI,
|
||||
core.ProfileWorker,
|
||||
core.ProfileSchedule,
|
||||
core.ProfileAll,
|
||||
}
|
||||
|
||||
for _, prof := range profiles {
|
||||
t.Run(string(prof), func(t *testing.T) {
|
||||
app := newWaveletApp(prof, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
}))
|
||||
require.NotNil(t, app)
|
||||
assert.Equal(t, prof, app.Profile())
|
||||
|
||||
// 3 infra + 2 cache + 4 worker/cron + 8 domain + 1 http driver = 18 plugins
|
||||
plugins := app.Plugins()
|
||||
assert.Len(t, plugins, 18)
|
||||
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
// Verify standard infra plugins
|
||||
_, ok := app.Plugin("database")
|
||||
assert.True(t, ok, "database plugin missing")
|
||||
|
||||
_, ok = app.Plugin("logger")
|
||||
assert.True(t, ok, "logger plugin missing")
|
||||
|
||||
_, ok = app.Plugin("storage")
|
||||
assert.True(t, ok, "storage plugin missing")
|
||||
|
||||
// In zero-Redis mode (default in test)
|
||||
f, ok := app.Fiber("cache_memory")
|
||||
assert.True(t, ok, "cache_memory fiber missing")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("cache")
|
||||
assert.True(t, ok, "cache fiber missing")
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_inproc_worker")
|
||||
assert.True(t, ok, "inproc worker driver fiber missing")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_asynq_worker")
|
||||
assert.True(t, ok, "asynq worker driver fiber missing")
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_inproc_cron")
|
||||
assert.True(t, ok, "inproc scheduler driver fiber missing")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_asynq_cron")
|
||||
assert.True(t, ok, "asynq scheduler driver fiber missing")
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
|
||||
// Verify domain plugins
|
||||
_, ok = app.Plugin("auth")
|
||||
assert.True(t, ok, "auth plugin missing")
|
||||
|
||||
_, ok = app.Plugin("user")
|
||||
assert.True(t, ok, "user plugin missing")
|
||||
|
||||
_, ok = app.Plugin("message_gateway")
|
||||
assert.True(t, ok, "message_gateway plugin missing")
|
||||
|
||||
_, ok = app.Plugin("risk_control")
|
||||
assert.True(t, ok, "risk_control plugin missing")
|
||||
|
||||
_, ok = app.Plugin("admin")
|
||||
assert.True(t, ok, "admin plugin missing")
|
||||
|
||||
_, ok = app.Plugin("upload")
|
||||
assert.True(t, ok, "upload plugin missing")
|
||||
|
||||
_, ok = app.Plugin("cap")
|
||||
assert.True(t, ok, "cap plugin missing")
|
||||
|
||||
_, ok = app.Plugin("system")
|
||||
assert.True(t, ok, "system plugin missing")
|
||||
|
||||
// Verify driver plugins
|
||||
_, ok = app.Plugin("driver_http")
|
||||
assert.True(t, ok, "http driver missing")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWaveletAppWithRedisEnabled(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
func testSource(t *testing.T) core.ConfigSource {
|
||||
t.Helper()
|
||||
return core.NewMapSource(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
"env": "testing",
|
||||
},
|
||||
}))
|
||||
require.NotNil(t, app)
|
||||
defer func() {
|
||||
_ = app.Stop(context.Background())
|
||||
_ = app.Context().Dispose()
|
||||
}()
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
f, ok := app.Fiber("cache")
|
||||
assert.True(t, ok, "cache plugin missing in Redis mode")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_asynq_worker")
|
||||
assert.True(t, ok, "asynq worker driver missing in Redis mode")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_asynq_cron")
|
||||
assert.True(t, ok, "asynq scheduler driver missing in Redis mode")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("cache_memory")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_inproc_worker")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
|
||||
f, ok = app.Fiber("driver_inproc_cron")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"database": map[string]any{
|
||||
"enabled": false,
|
||||
"sqlite_path": filepath.Join(t.TempDir(), "openflare-cmd.db"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) {
|
||||
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
|
||||
if err := app.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range app.Plugins() {
|
||||
names[p.Name()] = true
|
||||
}
|
||||
for _, n := range []string{"user", "auth", "cap", "admin", "server"} {
|
||||
if !names[n] {
|
||||
t.Errorf("missing plugin %s", n)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, rd := range app.Context().Router().Routes() {
|
||||
got[rd.Method+" "+rd.Path] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"GET /api/healthz",
|
||||
"GET /api/v1/user/self",
|
||||
"GET /api/v1/d/nodes",
|
||||
"POST /api/v1/cap/challenge",
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("missing route %s", want)
|
||||
}
|
||||
}
|
||||
for _, drop := range []string{
|
||||
"GET /api/health",
|
||||
"GET /healthz",
|
||||
"POST /api/cap/challenge",
|
||||
} {
|
||||
if got[drop] {
|
||||
t.Errorf("removed route still registered: %s", drop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshInstallSeedsOpenFlareDefaults(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh.db")
|
||||
app := cordisPrepare(t, cordisSQLiteSource(t, dbPath))
|
||||
t.Cleanup(func() { _ = app.Context().Dispose() })
|
||||
|
||||
db := openInspectDB(t, dbPath, "")
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
var tables int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%'`).Scan(&tables); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tables == 0 {
|
||||
t.Fatal("fresh install created no of_* tables")
|
||||
}
|
||||
|
||||
rows, err := db.Query(`SELECT task_type FROM w_schedules WHERE task_type LIKE 'of_%' ORDER BY 1`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var taskType string
|
||||
if err := rows.Scan(&taskType); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, taskType)
|
||||
}
|
||||
want := []string{
|
||||
"of_pages_source_scan",
|
||||
"of_ssl_renew",
|
||||
"of_uptime_kuma_sync",
|
||||
"of_waf_ip_group_sync",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("of_* schedules = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("of_* schedules = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
var cleanup int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM w_schedules WHERE task_type = 'of_database_auto_cleanup'`).Scan(&cleanup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleanup != 0 {
|
||||
t.Fatal("must not seed of_database_auto_cleanup")
|
||||
}
|
||||
|
||||
var geoip string
|
||||
if err := db.QueryRow(`SELECT value FROM w_system_configs WHERE key = 'geoip_provider'`).Scan(&geoip); err != nil {
|
||||
t.Fatalf("geoip_provider: %v", err)
|
||||
}
|
||||
if geoip != "ipinfo" {
|
||||
t.Fatalf("geoip_provider = %q, want ipinfo", geoip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,13 @@ func formatStartupBanner(state startupState) string {
|
||||
|
||||
lines := []string{
|
||||
"",
|
||||
"__ __ _ _ ",
|
||||
"\\ \\ / /_ ___ _____ | | ___| |_ ",
|
||||
" \\ \\ /\\ / / _` \\ \\ / / _ \\ | |/ _ \\ __|",
|
||||
" \\ V V / (_| |\\ V / __/ | | __/ |_ ",
|
||||
" \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|",
|
||||
fmt.Sprintf(" Wavelet %s", buildinfo.Version),
|
||||
" ____ ________ ",
|
||||
" / __ \\____ ___ ____ / ____/ /___ _________ ",
|
||||
" / / / / __ \\/ _ \\/ __ \\/ /_ / / __ `/ ___/ _ \\",
|
||||
"/ /_/ / /_/ / __/ / / / __/ / / /_/ / / / __/",
|
||||
"\\____/ .___/\\___/_/ /_/_/ /_/\\__,_/_/ \\___/ ",
|
||||
" /_/ ",
|
||||
fmt.Sprintf(" OpenFlare %s", buildinfo.Version),
|
||||
"",
|
||||
fmt.Sprintf(" Environment: %s", env),
|
||||
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestFormatStartupBanner(t *testing.T) {
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"Wavelet v3.2.1",
|
||||
"OpenFlare v3.2.1",
|
||||
"Environment: production",
|
||||
"Build time: 2026-07-13T08:00:00Z",
|
||||
"Listening: http://:3000",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command flared runs the OpenFlare tunnel client daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
flaredplugin "Wavelet/openflare/plugins/flared"
|
||||
edgelogging "Wavelet/openflare/share/edge/logging"
|
||||
)
|
||||
|
||||
// shutdownTimeout 为 frpc 子进程收敛预留的退出窗口。
|
||||
const shutdownTimeout = 60 * time.Second
|
||||
|
||||
func main() {
|
||||
edgelogging.Setup(edgelogging.Options{})
|
||||
|
||||
configPath := flag.String("config", "./flared.json", "flared config path")
|
||||
flag.Parse()
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.Profile(flaredplugin.DriverTypeFlared)),
|
||||
core.WithShutdownTimeout(shutdownTimeout),
|
||||
)
|
||||
app.Use(flaredplugin.New(*configPath))
|
||||
|
||||
if err := app.Prepare(); err != nil {
|
||||
slog.Error("flared startup failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
slog.Error("flared process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Wavelet/core"
|
||||
)
|
||||
|
||||
// baselineRoutesFile 是改造前遗留注册路径导出的 (方法 路径) 全集。
|
||||
const baselineRoutesFile = "docs/superpowers/specs/baseline/routes-engine.txt"
|
||||
|
||||
func TestPluginRoutesContainGoldenBaseline(t *testing.T) {
|
||||
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
|
||||
if err := app.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := routeSet(app.Context())
|
||||
want := loadBaseline(t)
|
||||
for _, drop := range []string{
|
||||
"GET /api/health",
|
||||
"GET /healthz",
|
||||
"POST /api/cap/challenge",
|
||||
"POST /api/cap/redeem",
|
||||
} {
|
||||
delete(want, drop)
|
||||
}
|
||||
for k := range want {
|
||||
if !got[k] {
|
||||
t.Errorf("missing golden route %s", k)
|
||||
}
|
||||
}
|
||||
for _, must := range []string{
|
||||
"GET /api/healthz",
|
||||
"POST /api/v1/cap/challenge",
|
||||
"POST /api/v1/cap/redeem",
|
||||
} {
|
||||
if !got[must] {
|
||||
t.Errorf("missing required route %s", must)
|
||||
}
|
||||
}
|
||||
for _, drop := range []string{
|
||||
"GET /api/health",
|
||||
"GET /healthz",
|
||||
"POST /api/cap/challenge",
|
||||
"POST /api/cap/redeem",
|
||||
} {
|
||||
if got[drop] {
|
||||
t.Errorf("removed route still registered: %s", drop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func routeSet(ctx *core.Context) map[string]bool {
|
||||
set := make(map[string]bool)
|
||||
for _, rd := range ctx.Router().Routes() {
|
||||
set[rd.Method+" "+rd.Path] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func loadBaseline(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
path := locateFile(t, baselineRoutesFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read baseline %s: %v", path, err)
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
set[line] = true
|
||||
}
|
||||
}
|
||||
if len(set) == 0 {
|
||||
t.Fatalf("baseline %s is empty", path)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func locateFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
dir := filepath.Dir(thisFile)
|
||||
for range 8 {
|
||||
candidate := filepath.Join(dir, rel)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
dir = filepath.Join(dir, "..")
|
||||
}
|
||||
t.Fatalf("%s not found above %s", rel, filepath.Dir(thisFile))
|
||||
return ""
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/idgen"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRedisPluggability_Simulation(t *testing.T) {
|
||||
_ = idgen.Init(1)
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 场景 1: 拔出 Redis (Zero-Redis Monolith 模式)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
t.Run("Scenario_Unplugged_ZeroRedis_Mode", func(t *testing.T) {
|
||||
app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
}))
|
||||
require.NotNil(t, app)
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
// 1. 验证插件挂载形态
|
||||
f, ok := app.Fiber("cache_memory")
|
||||
assert.True(t, ok, "cache_memory 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_inproc_worker")
|
||||
assert.True(t, ok, "driver_inproc_worker 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_inproc_cron")
|
||||
assert.True(t, ok, "driver_inproc_cron 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("cache")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "分布式 cache 不得挂载")
|
||||
f, ok = app.Fiber("driver_asynq_worker")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "asynq_worker 不得挂载")
|
||||
f, ok = app.Fiber("driver_asynq_cron")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "asynq_cron 不得挂载")
|
||||
|
||||
// 2. 注册测试任务与 Cron 定时
|
||||
var taskExecuted atomic.Int32
|
||||
var cronExecuted atomic.Int32
|
||||
|
||||
app.Context().Tasks().Register("test:inproc_task", func(ctx context.Context, payload []byte) error {
|
||||
if string(payload) == "payload_unplugged" {
|
||||
taskExecuted.Add(1)
|
||||
}
|
||||
return nil
|
||||
}, extpoints.WithTaskTimeout(3*time.Second))
|
||||
|
||||
app.Context().Schedules().RegisterCron("* * * * * *", "test:inproc_cron", []byte("cron_ping"))
|
||||
app.Context().Tasks().Register("test:inproc_cron", func(ctx context.Context, payload []byte) error {
|
||||
if string(payload) == "cron_ping" {
|
||||
cronExecuted.Add(1)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 3. 启动应用
|
||||
bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer bootCancel()
|
||||
require.NoError(t, app.Start(bootCtx))
|
||||
|
||||
// 4. 验证 CacheService 操作
|
||||
cacheSvc, err := core.Inject[contracts.CacheService](app.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cacheSvc)
|
||||
|
||||
reqCtx := context.Background()
|
||||
require.NoError(t, cacheSvc.Set(reqCtx, "unplugged_key", "value_123", time.Minute))
|
||||
var val string
|
||||
require.NoError(t, cacheSvc.Get(reqCtx, "unplugged_key", &val))
|
||||
assert.Equal(t, "value_123", val)
|
||||
|
||||
// 5. 验证异步 Worker 任务分发与执行
|
||||
taskSvc, err := core.Inject[contracts.TaskService](app.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, taskSvc)
|
||||
|
||||
taskID, err := taskSvc.Dispatch(reqCtx, "test:inproc_task", []byte("payload_unplugged"), "unit_test")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return taskExecuted.Load() >= 1
|
||||
}, 3*time.Second, 50*time.Millisecond, "内存 Worker 应在进程内顺利执行任务")
|
||||
|
||||
// 6. 验证 Cron 定时触发
|
||||
require.Eventually(t, func() bool {
|
||||
return cronExecuted.Load() >= 1
|
||||
}, 3*time.Second, 100*time.Millisecond, "内存 Cron 驱动应成功触发定时任务")
|
||||
|
||||
// 7. 优雅关闭
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stopCancel()
|
||||
require.NoError(t, app.Stop(stopCtx))
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 场景 2: 插入 Redis (Distributed Cluster 模式)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
t.Run("Scenario_Plugged_Redis_Mode", func(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NotNil(t, app)
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
// 1. 验证插件挂载形态
|
||||
f, ok := app.Fiber("cache")
|
||||
assert.True(t, ok, "分布式 cache 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_asynq_worker")
|
||||
assert.True(t, ok, "driver_asynq_worker 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_asynq_cron")
|
||||
assert.True(t, ok, "driver_asynq_cron 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
f, ok = app.Fiber("cache_memory")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "纯内存 cache 不得挂载")
|
||||
f, ok = app.Fiber("driver_inproc_worker")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "inproc_worker 不得挂载")
|
||||
f, ok = app.Fiber("driver_inproc_cron")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "inproc_cron 不得挂载")
|
||||
|
||||
// 2. 注册测试任务
|
||||
var asynqTaskExecuted atomic.Int32
|
||||
app.Context().Tasks().Register("test:asynq_task", func(ctx context.Context, payload []byte) error {
|
||||
if string(payload) == "payload_plugged" {
|
||||
asynqTaskExecuted.Add(1)
|
||||
}
|
||||
return nil
|
||||
}, extpoints.WithTaskTimeout(3*time.Second))
|
||||
|
||||
// 3. 启动应用
|
||||
bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer bootCancel()
|
||||
require.NoError(t, app.Start(bootCtx))
|
||||
|
||||
// 4. 验证 CacheService 操作 (L1 RAM + L2 Redis)
|
||||
cacheSvc, err := core.Inject[contracts.CacheService](app.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cacheSvc)
|
||||
|
||||
reqCtx := context.Background()
|
||||
testKey := fmt.Sprintf("plugged_key_%d", time.Now().UnixNano())
|
||||
require.NoError(t, cacheSvc.Set(reqCtx, testKey, "value_redis_cluster", time.Minute))
|
||||
|
||||
var val string
|
||||
require.NoError(t, cacheSvc.Get(reqCtx, testKey, &val))
|
||||
assert.Equal(t, "value_redis_cluster", val)
|
||||
|
||||
// 验证失效广播与删除
|
||||
require.NoError(t, cacheSvc.Delete(reqCtx, testKey))
|
||||
var valAfterDelete string
|
||||
err = cacheSvc.Get(reqCtx, testKey, &valAfterDelete)
|
||||
assert.ErrorIs(t, err, contracts.ErrCacheMiss)
|
||||
|
||||
// 5. 验证 Asynq Worker 任务分发与消费
|
||||
taskSvc, err := core.Inject[contracts.TaskService](app.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, taskSvc)
|
||||
|
||||
taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "default")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return asynqTaskExecuted.Load() >= 1
|
||||
}, 10*time.Second, 100*time.Millisecond, "Asynq Worker 应从 Redis 队列中成功消费并执行任务")
|
||||
|
||||
// 6. 优雅关闭
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stopCancel()
|
||||
require.NoError(t, app.Stop(stopCtx))
|
||||
_ = app.Context().Dispose()
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 场景 3: 往复插拔连续切换 (拔出 → 插入 → 再拔出,验证时空可组合性与零残留)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
t.Run("Scenario_Dynamic_Plug_Unplug_Sequence", func(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
// 1. 拔出 Redis 运行
|
||||
appUnplugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
}))
|
||||
require.NoError(t, appUnplugged.Start(context.Background()))
|
||||
|
||||
cacheSvc1, err := core.Inject[contracts.CacheService](appUnplugged.Context())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cacheSvc1.Set(context.Background(), fmt.Sprintf("seq_key_%d", i), "seq_val_unplugged", time.Minute))
|
||||
|
||||
require.NoError(t, appUnplugged.Stop(context.Background()))
|
||||
_ = appUnplugged.Context().Dispose()
|
||||
|
||||
// 2. 插入 Redis 运行
|
||||
appPlugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, appPlugged.Start(context.Background()))
|
||||
|
||||
cacheSvc2, err := core.Inject[contracts.CacheService](appPlugged.Context())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cacheSvc2.Set(context.Background(), fmt.Sprintf("seq_key_%d", i), "seq_val_plugged", time.Minute))
|
||||
|
||||
require.NoError(t, appPlugged.Stop(context.Background()))
|
||||
_ = appPlugged.Context().Dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command relay runs the OpenFlare relay node daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
relayplugin "Wavelet/openflare/plugins/relay"
|
||||
edgelogging "Wavelet/openflare/share/edge/logging"
|
||||
)
|
||||
|
||||
// shutdownTimeout 为 frps 子进程收敛预留的退出窗口。
|
||||
const shutdownTimeout = 60 * time.Second
|
||||
|
||||
func main() {
|
||||
edgelogging.Setup(edgelogging.Options{})
|
||||
|
||||
configPath := flag.String("config", "./relay.json", "relay config path")
|
||||
flag.Parse()
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.Profile(relayplugin.DriverTypeRelay)),
|
||||
core.WithShutdownTimeout(shutdownTimeout),
|
||||
)
|
||||
app.Use(relayplugin.New(*configPath))
|
||||
|
||||
if err := app.Prepare(); err != nil {
|
||||
slog.Error("relay startup failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
slog.Error("relay process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
goldenRoot = "/Users/ryan/Code/Go/OpenFlare"
|
||||
goldCommit = "9f79fb99"
|
||||
goldGooseVersion = int64(202608090003)
|
||||
sampleZoneDomain = "l3-upgrade-golden.example"
|
||||
goldMigrateWait = 75 * time.Second
|
||||
legacyPluginStamp = "openflare/legacy"
|
||||
serverPluginStamp = "server"
|
||||
)
|
||||
|
||||
var (
|
||||
goldBinOnce sync.Once
|
||||
goldBinPath string
|
||||
goldSrcDir string
|
||||
goldBinErr error
|
||||
)
|
||||
|
||||
func TestUpgradeFromGolden(t *testing.T) {
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "a.db")
|
||||
runGoldenAPI(t, tmp, goldSQLiteEnv(t, tmp, dbPath), func() bool {
|
||||
return sqliteReady(dbPath)
|
||||
})
|
||||
assertUpgradeFromGolden(t, upgradeDB{
|
||||
sqlitePath: dbPath,
|
||||
source: cordisSQLiteSource(t, dbPath),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpgradePostgresFromGolden(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_PG_DSN is not set")
|
||||
}
|
||||
|
||||
host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn)
|
||||
adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode)
|
||||
admin := openInspectDB(t, "", adminDSN)
|
||||
t.Cleanup(func() { _ = admin.Close() })
|
||||
|
||||
dbName := fmt.Sprintf("of_l3_%d", time.Now().UnixNano())
|
||||
if !safePGIdent(dbName) {
|
||||
t.Fatalf("generated database name %q is not a safe identifier", dbName)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil {
|
||||
t.Fatalf("CREATE DATABASE %s: %v", dbName, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName)
|
||||
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName)
|
||||
})
|
||||
|
||||
tmp := t.TempDir()
|
||||
testDSN := postgresDSN(host, port, user, pass, dbName, sslMode)
|
||||
runGoldenAPI(t, tmp, goldPostgresEnv(t, tmp, host, port, user, pass, dbName, sslMode), func() bool {
|
||||
return postgresReady(testDSN)
|
||||
})
|
||||
assertUpgradeFromGolden(t, upgradeDB{
|
||||
pgDSN: testDSN,
|
||||
source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode),
|
||||
})
|
||||
}
|
||||
|
||||
type upgradeDB struct {
|
||||
sqlitePath string
|
||||
pgDSN string
|
||||
source core.ConfigSource
|
||||
}
|
||||
|
||||
func assertUpgradeFromGolden(t *testing.T, spec upgradeDB) {
|
||||
t.Helper()
|
||||
|
||||
inspect := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
|
||||
before := dumpOfSchema(t, inspect, spec.pgDSN != "")
|
||||
insertSQL := `INSERT INTO of_zones (domain) VALUES (?)`
|
||||
if spec.pgDSN != "" {
|
||||
insertSQL = `INSERT INTO of_zones (domain) VALUES ($1)`
|
||||
}
|
||||
if _, err := inspect.Exec(insertSQL, sampleZoneDomain); err != nil {
|
||||
t.Fatalf("insert sample of_zones row: %v", err)
|
||||
}
|
||||
_ = inspect.Close()
|
||||
|
||||
app := cordisPrepare(t, spec.source)
|
||||
legacyRows := schemaPluginRows(t, spec, legacyPluginStamp)
|
||||
assertStampedUpgrade(t, spec, before, legacyRows)
|
||||
if err := app.Context().Dispose(); err != nil {
|
||||
t.Fatalf("dispose first app: %v", err)
|
||||
}
|
||||
|
||||
app2 := cordisPrepare(t, spec.source)
|
||||
t.Cleanup(func() { _ = app2.Context().Dispose() })
|
||||
if got := schemaPluginRows(t, spec, legacyPluginStamp); got != legacyRows {
|
||||
t.Fatalf("second Prepare increased %s rows: got %d, want %d", legacyPluginStamp, got, legacyRows)
|
||||
}
|
||||
assertStampedUpgrade(t, spec, before, legacyRows)
|
||||
}
|
||||
|
||||
func cordisPrepare(t *testing.T, src core.ConfigSource) *core.App {
|
||||
t.Helper()
|
||||
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(src))
|
||||
if err := app.Prepare(); err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if err := app.ApplyPlugins(); err != nil {
|
||||
t.Fatalf("ApplyPlugins: %v", err)
|
||||
}
|
||||
if err := app.RunMigrations(); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func assertStampedUpgrade(t *testing.T, spec upgradeDB, before map[string][]string, legacyRows int) {
|
||||
t.Helper()
|
||||
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
|
||||
defer func() { _ = db.Close() }()
|
||||
postgres := spec.pgDSN != ""
|
||||
|
||||
if got := gooseMaxVersion(t, db); got != goldGooseVersion {
|
||||
t.Errorf("goose_db_version max = %d, want %d", got, goldGooseVersion)
|
||||
}
|
||||
if legacyRows < 2 {
|
||||
t.Errorf("w_schema_versions %s rows = %d, want at least 2 (0 and %d)", legacyPluginStamp, legacyRows, goldGooseVersion)
|
||||
}
|
||||
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, 0) {
|
||||
t.Errorf("missing w_schema_versions (%s, 0)", legacyPluginStamp)
|
||||
}
|
||||
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, goldGooseVersion) {
|
||||
t.Errorf("missing w_schema_versions (%s, %d)", legacyPluginStamp, goldGooseVersion)
|
||||
}
|
||||
if !pluginHasVersion(t, db, postgres, serverPluginStamp, 1) {
|
||||
t.Errorf("missing w_schema_versions (%s, 1)", serverPluginStamp)
|
||||
}
|
||||
|
||||
var domain string
|
||||
q := `SELECT domain FROM of_zones WHERE domain = ?`
|
||||
if postgres {
|
||||
q = `SELECT domain FROM of_zones WHERE domain = $1`
|
||||
}
|
||||
if err := db.QueryRow(q, sampleZoneDomain).Scan(&domain); err != nil {
|
||||
t.Errorf("sample of_zones row missing after upgrade: %v", err)
|
||||
}
|
||||
|
||||
after := dumpOfSchema(t, db, postgres)
|
||||
for table, cols := range before {
|
||||
got, ok := after[table]
|
||||
if !ok {
|
||||
t.Errorf("of_* table %s dropped", table)
|
||||
continue
|
||||
}
|
||||
have := make(map[string]bool, len(got))
|
||||
for _, c := range got {
|
||||
have[c] = true
|
||||
}
|
||||
for _, c := range cols {
|
||||
if !have[c] {
|
||||
t.Errorf("of_* column %s.%s dropped", table, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runGoldenAPI(t *testing.T, workDir string, env []string, ready func() bool) {
|
||||
t.Helper()
|
||||
bin := buildGoldenBinary(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), goldMigrateWait)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "api")
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = env
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start golden api: %v", err)
|
||||
}
|
||||
|
||||
waitErr := make(chan error, 1)
|
||||
go func() { waitErr <- cmd.Wait() }()
|
||||
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ready() {
|
||||
killGolden(cmd)
|
||||
<-waitErr
|
||||
return
|
||||
}
|
||||
select {
|
||||
case err := <-waitErr:
|
||||
if ready() {
|
||||
return
|
||||
}
|
||||
t.Fatalf("golden api exited before goose %d: %v\n%s", goldGooseVersion, err, out.String())
|
||||
case <-ctx.Done():
|
||||
killGolden(cmd)
|
||||
<-waitErr
|
||||
t.Fatalf("timeout waiting for golden goose %d\n%s", goldGooseVersion, out.String())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func killGolden(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func buildGoldenBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
goldBinOnce.Do(func() {
|
||||
src, err := os.MkdirTemp("", "of-gold-src-")
|
||||
if err != nil {
|
||||
goldBinErr = err
|
||||
return
|
||||
}
|
||||
archive := exec.Command("git", "-C", goldenRoot, "archive", goldCommit)
|
||||
extract := exec.Command("tar", "-x", "-C", src)
|
||||
pipe, err := archive.StdoutPipe()
|
||||
if err != nil {
|
||||
goldBinErr = fmt.Errorf("gold archive pipe: %w", err)
|
||||
return
|
||||
}
|
||||
extract.Stdin = pipe
|
||||
var archiveErr, extractErr bytes.Buffer
|
||||
archive.Stderr = &archiveErr
|
||||
extract.Stderr = &extractErr
|
||||
if err := archive.Start(); err != nil {
|
||||
goldBinErr = fmt.Errorf("git archive %s: %w", goldCommit, err)
|
||||
return
|
||||
}
|
||||
if err := extract.Start(); err != nil {
|
||||
_ = archive.Process.Kill()
|
||||
goldBinErr = fmt.Errorf("extract gold %s: %w", goldCommit, err)
|
||||
return
|
||||
}
|
||||
if err := extract.Wait(); err != nil {
|
||||
_ = archive.Wait()
|
||||
goldBinErr = fmt.Errorf("extract gold %s: %w\n%s", goldCommit, err, extractErr.String())
|
||||
return
|
||||
}
|
||||
if err := archive.Wait(); err != nil {
|
||||
goldBinErr = fmt.Errorf("git archive %s: %w\n%s", goldCommit, err, archiveErr.String())
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(src, "main.go")); err != nil {
|
||||
goldBinErr = fmt.Errorf("gold %s at %s: %w", goldCommit, src, err)
|
||||
return
|
||||
}
|
||||
goldSrcDir = src
|
||||
|
||||
dir, err := os.MkdirTemp("", "of-gold-bin-")
|
||||
if err != nil {
|
||||
goldBinErr = err
|
||||
return
|
||||
}
|
||||
out := filepath.Join(dir, "gold")
|
||||
cmd := exec.Command("go", "build", "-o", out, ".")
|
||||
cmd.Dir = src
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
if err := cmd.Run(); err != nil {
|
||||
goldBinErr = fmt.Errorf("go build golden %s: %w\n%s", goldCommit, err, buf.String())
|
||||
return
|
||||
}
|
||||
goldBinPath = out
|
||||
})
|
||||
if goldBinErr != nil {
|
||||
t.Fatalf("%v", goldBinErr)
|
||||
}
|
||||
return goldBinPath
|
||||
}
|
||||
|
||||
func copyGoldConfig(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
buildGoldenBinary(t)
|
||||
dst := filepath.Join(dir, "config.yaml")
|
||||
src, err := os.Open(filepath.Join(goldSrcDir, "config.example.yaml")) //nolint:gosec // extracted gold snapshot
|
||||
if err != nil {
|
||||
t.Fatalf("open golden config.example.yaml: %v", err)
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // test temp file
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config.yaml: %v", err)
|
||||
}
|
||||
if _, err := io.Copy(out, src); err != nil {
|
||||
_ = out.Close()
|
||||
t.Fatalf("copy golden config: %v", err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
t.Fatalf("close temp config.yaml: %v", err)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func goldSQLiteEnv(t *testing.T, dir, dbPath string) []string {
|
||||
t.Helper()
|
||||
cfg := copyGoldConfig(t, dir)
|
||||
addr := freeLocalAddr(t)
|
||||
return filteredGoldEnv(
|
||||
"CONFIG_PATH="+cfg,
|
||||
"SQLITE_PATH="+dbPath,
|
||||
"DB_ENABLED=false",
|
||||
"REDIS_ENABLED=false",
|
||||
"CLICKHOUSE_ENABLED=false",
|
||||
"APP_ENV=testing",
|
||||
"APP_ADDR="+addr,
|
||||
)
|
||||
}
|
||||
|
||||
func goldPostgresEnv(t *testing.T, dir, host string, port int, user, pass, dbName, sslMode string) []string {
|
||||
t.Helper()
|
||||
cfg := copyGoldConfig(t, dir)
|
||||
addr := freeLocalAddr(t)
|
||||
return filteredGoldEnv(
|
||||
"CONFIG_PATH="+cfg,
|
||||
"DB_ENABLED=true",
|
||||
"DB_HOST="+host,
|
||||
"DB_PORT="+strconv.Itoa(port),
|
||||
"DB_USERNAME="+user,
|
||||
"DB_PASSWORD="+pass,
|
||||
"DB_NAME="+dbName,
|
||||
"DB_SSL_MODE="+sslMode,
|
||||
"REDIS_ENABLED=false",
|
||||
"CLICKHOUSE_ENABLED=false",
|
||||
"APP_ENV=testing",
|
||||
"APP_ADDR="+addr,
|
||||
)
|
||||
}
|
||||
|
||||
func filteredGoldEnv(extra ...string) []string {
|
||||
drop := map[string]bool{
|
||||
"CONFIG_PATH": true,
|
||||
"SQLITE_PATH": true,
|
||||
"DB_ENABLED": true,
|
||||
"DB_HOST": true,
|
||||
"DB_PORT": true,
|
||||
"DB_USERNAME": true,
|
||||
"DB_PASSWORD": true,
|
||||
"DB_NAME": true,
|
||||
"DB_SSL_MODE": true,
|
||||
"REDIS_ENABLED": true,
|
||||
"REDIS_ADDR": true,
|
||||
"CLICKHOUSE_ENABLED": true,
|
||||
"CLICKHOUSE_HOST": true,
|
||||
"APP_ENV": true,
|
||||
"APP_ADDR": true,
|
||||
}
|
||||
env := make([]string, 0, len(os.Environ())+len(extra))
|
||||
for _, kv := range os.Environ() {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if drop[k] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
return append(env, extra...)
|
||||
}
|
||||
|
||||
func freeLocalAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen for free port: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
func cordisSQLiteSource(t *testing.T, dbPath string) core.ConfigSource {
|
||||
t.Helper()
|
||||
return core.NewMapSource(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
"env": "testing",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"clickhouse": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"database": map[string]any{
|
||||
"enabled": false,
|
||||
"sqlite_path": dbPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func cordisPostgresSource(t *testing.T, host string, port int, user, pass, dbName, sslMode string) core.ConfigSource {
|
||||
t.Helper()
|
||||
return core.NewMapSource(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
"env": "testing",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"clickhouse": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"database": map[string]any{
|
||||
"enabled": true,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"database": dbName,
|
||||
"ssl_mode": sslMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteReady(path string) bool {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return false
|
||||
}
|
||||
gdb, err := gorm.Open(sqlite.Open("file:"+path+"?mode=ro&_pragma=busy_timeout(1000)"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
return migratedReady(sqlDB, false)
|
||||
}
|
||||
|
||||
func postgresReady(dsn string) bool {
|
||||
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
return migratedReady(sqlDB, true)
|
||||
}
|
||||
|
||||
func migratedReady(db *sql.DB, postgres bool) bool {
|
||||
if gooseMaxVersionSilent(db) != goldGooseVersion {
|
||||
return false
|
||||
}
|
||||
var n int
|
||||
var err error
|
||||
if postgres {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'of_nodes'`).Scan(&n)
|
||||
} else {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'of_nodes'`).Scan(&n)
|
||||
}
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
func openInspectDB(t *testing.T, sqlitePath, pgDSN string) *sql.DB {
|
||||
t.Helper()
|
||||
var gdb *gorm.DB
|
||||
var err error
|
||||
if pgDSN != "" {
|
||||
gdb, err = gorm.Open(postgres.Open(pgDSN), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
} else {
|
||||
gdb, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("open inspect db: %v", err)
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("inspect sql.DB: %v", err)
|
||||
}
|
||||
return sqlDB
|
||||
}
|
||||
|
||||
func dumpOfSchema(t *testing.T, db *sql.DB, postgres bool) map[string][]string {
|
||||
t.Helper()
|
||||
tables := ofTables(t, db, postgres)
|
||||
out := make(map[string][]string, len(tables))
|
||||
for _, table := range tables {
|
||||
out[table] = ofColumns(t, db, postgres, table)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no of_* tables in golden database")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ofTables(t *testing.T, db *sql.DB, postgres bool) []string {
|
||||
t.Helper()
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if postgres {
|
||||
rows, err = db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'of_%' ORDER BY tablename`)
|
||||
} else {
|
||||
rows, err = db.Query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%' ORDER BY name`)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("list of_* tables: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan of_* table: %v", err)
|
||||
}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("list of_* tables: %v", err)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
func ofColumns(t *testing.T, db *sql.DB, postgres bool, table string) []string {
|
||||
t.Helper()
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if postgres {
|
||||
rows, err = db.Query(`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, table)
|
||||
} else {
|
||||
rows, err = db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("list columns for %s: %v", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan column for %s: %v", table, err)
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("list columns for %s: %v", table, err)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func gooseMaxVersion(t *testing.T, db *sql.DB) int64 {
|
||||
t.Helper()
|
||||
v := gooseMaxVersionSilent(db)
|
||||
if v < 0 {
|
||||
t.Fatal("read goose_db_version max failed")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func gooseMaxVersionSilent(db *sql.DB) int64 {
|
||||
var v int64
|
||||
if err := db.QueryRow(`SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version`).Scan(&v); err != nil {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func schemaPluginRows(t *testing.T, spec upgradeDB, pluginID string) int {
|
||||
t.Helper()
|
||||
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
|
||||
defer func() { _ = db.Close() }()
|
||||
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ?`
|
||||
if spec.pgDSN != "" {
|
||||
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1`
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(q, pluginID).Scan(&n); err != nil {
|
||||
t.Fatalf("count w_schema_versions %s: %v", pluginID, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func pluginHasVersion(t *testing.T, db *sql.DB, postgres bool, pluginID string, version int64) bool {
|
||||
t.Helper()
|
||||
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ? AND version_id = ?`
|
||||
if postgres {
|
||||
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1 AND version_id = $2`
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(q, pluginID, version).Scan(&n); err != nil {
|
||||
t.Fatalf("lookup w_schema_versions (%s, %d): %v", pluginID, version, err)
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func parsePostgresDSN(t *testing.T, dsn string) (host string, port int, user, pass, dbName, sslMode string) {
|
||||
t.Helper()
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("TEST_PG_DSN: %v", err)
|
||||
}
|
||||
host = u.Hostname()
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port = 5432
|
||||
if p := u.Port(); p != "" {
|
||||
port, err = strconv.Atoi(p)
|
||||
if err != nil {
|
||||
t.Fatalf("TEST_PG_DSN port: %v", err)
|
||||
}
|
||||
}
|
||||
if u.User != nil {
|
||||
user = u.User.Username()
|
||||
pass, _ = u.User.Password()
|
||||
}
|
||||
dbName = strings.Trim(u.Path, "/")
|
||||
if dbName == "" {
|
||||
dbName = "postgres"
|
||||
}
|
||||
sslMode = u.Query().Get("sslmode")
|
||||
if sslMode == "" {
|
||||
sslMode = "disable"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func postgresDSN(host string, port int, user, pass, dbName, sslMode string) string {
|
||||
u := &url.URL{
|
||||
Scheme: "postgres",
|
||||
Host: net.JoinHostPort(host, strconv.Itoa(port)),
|
||||
Path: dbName,
|
||||
}
|
||||
if user != "" {
|
||||
u.User = url.UserPassword(user, pass)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sslmode", sslMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
var pgIdent = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
|
||||
|
||||
func safePGIdent(name string) bool {
|
||||
return pgIdent.MatchString(name)
|
||||
}
|
||||
+13488
-171
File diff suppressed because it is too large
Load Diff
+13488
-171
File diff suppressed because it is too large
Load Diff
+8360
-118
File diff suppressed because it is too large
Load Diff
+47
-33
@@ -10,12 +10,16 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.34
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5
|
||||
github.com/bodgit/sevenzip v1.6.5
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/deepteams/webp v1.2.7
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.2
|
||||
github.com/expr-lang/expr v1.17.8
|
||||
github.com/gin-contrib/sessions v1.1.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -23,6 +27,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hibiken/asynq v0.26.0
|
||||
github.com/maypok86/otter/v2 v2.3.0
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/peterbourgon/diskv/v3 v3.0.1
|
||||
github.com/pressly/goose/v3 v3.27.3
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.22.0
|
||||
@@ -37,7 +42,9 @@ require (
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
github.com/ulikunitz/xz v0.5.16
|
||||
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.70.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
|
||||
go.opentelemetry.io/otel v1.45.0
|
||||
@@ -48,6 +55,7 @@ require (
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.22.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -57,7 +65,7 @@ require (
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/plugin/dbresolver v1.6.2
|
||||
gorm.io/plugin/opentelemetry v0.1.14
|
||||
gorm.io/plugin/opentelemetry v0.1.16
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -79,48 +87,51 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
|
||||
github.com/aws/smithy-go v1.27.6 // indirect
|
||||
github.com/boj/redistore v1.4.1 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/boj/redistore v1.4.2 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/go-sqlite v1.23.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-faster/errors v0.8.0 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/spec v0.22.9 // indirect
|
||||
github.com/go-openapi/swag/conv v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/go-resty/resty/v2 v2.6.0 // indirect
|
||||
github.com/go-resty/resty/v2 v2.17.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gomodule/redigo v1.9.3 // indirect
|
||||
github.com/google/btree v1.0.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -128,19 +139,20 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/paulmach/orb v0.13.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.28 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.61.0 // indirect
|
||||
@@ -152,37 +164,39 @@ require (
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.9.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.2 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.12.2 // indirect
|
||||
go.opentelemetry.io/otel/log v0.6.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/arch v0.29.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/grpc v1.83.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
modernc.org/libc v1.74.3 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.54.0 // indirect
|
||||
modernc.org/sqlite v1.56.0 // indirect
|
||||
)
|
||||
|
||||
exclude github.com/gomodule/redigo v2.0.0+incompatible
|
||||
|
||||
+99
-64
@@ -124,8 +124,14 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/boj/redistore v1.4.1 h1:lP9ZZWqKMq2RIqexlZX1w1ODSnegL+puxGIujkU5tIw=
|
||||
github.com/boj/redistore v1.4.1/go.mod h1:c0Tvw6aMjslog4jHIAcNv6EtJM849YoOAhMY7JBbWpI=
|
||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng=
|
||||
github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/boj/redistore v1.4.2 h1:44FVJnBTdzDV9VpaByCOaQs0ND8hzABD2xBHcAIbX9s=
|
||||
github.com/boj/redistore v1.4.2/go.mod h1:jjh65GXAH+5lj29pPRnQHdRNOt/lmP0LOaK+fiG2Fu8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -171,10 +177,15 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deepteams/webp v1.2.7 h1:Oj3iXbZ0U4siY1KHFTF5T21uysZkmor8pT4E38mNK2o=
|
||||
github.com/deepteams/webp v1.2.7/go.mod h1:J8Ap+HAixxpKKRN9IpEeSKlfvhsef1v43jKTO7m3f4c=
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.2 h1:x0cvjmUKxt764Yxdk2nr94we1AvPPAMh1rh5TQ+Jo80=
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.2/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -189,6 +200,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m
|
||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
|
||||
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
||||
@@ -201,8 +214,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
@@ -214,14 +227,16 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
|
||||
github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc=
|
||||
github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
@@ -238,29 +253,33 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
||||
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
|
||||
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
|
||||
github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw=
|
||||
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
|
||||
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
|
||||
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
|
||||
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
|
||||
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
|
||||
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
|
||||
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
|
||||
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
|
||||
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
|
||||
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
@@ -274,14 +293,15 @@ github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
|
||||
@@ -327,8 +347,9 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
|
||||
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
|
||||
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -368,8 +389,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -394,8 +415,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
|
||||
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -419,7 +440,6 @@ github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaX
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
@@ -453,16 +473,17 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -506,6 +527,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
@@ -518,8 +541,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
@@ -532,6 +556,8 @@ github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vv
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
|
||||
@@ -542,8 +568,8 @@ github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdD
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU=
|
||||
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
|
||||
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -628,11 +654,15 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||
github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
@@ -669,8 +699,10 @@ github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
|
||||
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 h1:cj/Z6FKTTYBnstI0Lni9PA+k2foounKIPUmj1LBwNiQ=
|
||||
@@ -683,8 +715,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
|
||||
@@ -716,8 +748,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MC
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
|
||||
go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc=
|
||||
go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
|
||||
go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8=
|
||||
go.opentelemetry.io/otel/log v0.6.0/go.mod h1:KdySypjQHhP069JX0z/t26VHwa8vSwzgaKmXtIB3fJM=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
@@ -742,8 +774,11 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
||||
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@@ -1008,8 +1043,8 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -1068,8 +1103,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -1293,8 +1328,8 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
|
||||
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
|
||||
gorm.io/plugin/opentelemetry v0.1.14 h1:xivP39t/0JgcceDl+BLwVAJHihjFEUj0ZocMSBwZ7ZY=
|
||||
gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g=
|
||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
@@ -1314,8 +1349,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
|
||||
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -1324,8 +1359,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package main 是 Wavelet 平台的程序入口
|
||||
// Package main 是 OpenFlare 平台的程序入口
|
||||
package main
|
||||
|
||||
import "Wavelet/cmd"
|
||||
|
||||
// @title Wavelet API
|
||||
// @title OpenFlare API
|
||||
// @version 1.0.0
|
||||
// @description Wavelet 平台后端 API,提供用户认证、系统配置、任务调度等通用功能。
|
||||
// @contact.name Wavelet
|
||||
// @contact.url https://github.com/Rain-kl/Wavelet
|
||||
// @description OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。
|
||||
// @contact.name OpenFlare
|
||||
// @contact.url https://github.com/Rain-kl/OpenFlare
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
// @BasePath /
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# OpenFlare 下游树
|
||||
|
||||
本目录占据上游 `backend/downstream/` 的位置,存放 OpenFlare 的全部业务,
|
||||
按功能职责拆为 **4 个插件 + 1 个共享层**。上游目录
|
||||
(`backend/{core,pkg,plugins}`)通过 `git fetch wavelet && git merge wavelet/main`
|
||||
吸收(第一次接线已 merge `wavelet/feat/cordis-alignment`,待该分支合入上游 main
|
||||
后改走 `wavelet/main`)。本目录、`frontend/` 与 `backend/cmd` 由本仓库持有。
|
||||
合并前请 `git config include.path ../.gitconfig`(或 worktree 安全写法
|
||||
`git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"`),
|
||||
以启用 `.gitattributes` 的 `merge=ours`。
|
||||
|
||||
```
|
||||
backend/openflare/
|
||||
├── plugins/
|
||||
│ ├── server/ # 控制面插件:站点/区域/Cloudflare/Pages/WAF/节点/回源/健康/配置版本
|
||||
│ │ ├── openflare/ admin/ oauth/ user/ upload/ cap/ config/ health/ # 业务域
|
||||
│ │ ├── repository/ model/ infra/ shared/ pkg/ # 支撑层
|
||||
│ │ └── router/ platform/ listener/ integration/ testhelper/ # 装配与接线
|
||||
│ ├── agent/ # 边缘 nginx/WAF 代理守护进程插件
|
||||
│ ├── relay/ # frps 中继守护进程插件
|
||||
│ └── flared/ # frpc 隧道客户端守护进程插件
|
||||
└── share/ # 插件间共享资源(见 share/README.md)
|
||||
```
|
||||
|
||||
装配根在 `backend/cmd`(与上游同构):`main.go` + `cmd/*.go` 为 server 的
|
||||
api/worker/schedule/all profile 入口,`cmd/{agent,relay,flared}/main.go` 为三个
|
||||
守护进程入口。
|
||||
|
||||
## 依赖规则
|
||||
|
||||
1. `plugins/<A>` 与 `plugins/<B>` 之间禁止互相 import;需要协作时走 `core/contracts`
|
||||
或 `ctx.Events()`。
|
||||
2. 插件只允许 import 本插件内部包、`Wavelet/core`、`Wavelet/core/contracts`、
|
||||
`Wavelet/pkg` 与 `Wavelet/openflare/share`。
|
||||
3. `share/` 禁止 import 任何插件实现与下游业务包。
|
||||
4. 表单一所有者:`of_*` 全部由 `server` 插件建表与读写;`w_*` 由上游平台插件拥有,
|
||||
下游只能经契约或事件访问。
|
||||
|
||||
## 收敛路线
|
||||
|
||||
- 已完成:`agent`/`relay`/`flared` 各有 `plugin.go` 实现 `core.Plugin` + `core.Driver`
|
||||
(`DriverTypeAgent`/`DriverTypeRelay`/`DriverTypeFlared`),入口 `backend/cmd/{agent,relay,flared}/main.go`
|
||||
已改为 `core.NewApp(core.WithProfile(...))` + `app.Run()` 装配,`-config` 旗标、
|
||||
默认路径、退出码与启动日志保持原样;JSON 配置由各插件 `Apply` 自行加载。
|
||||
- 已完成:`server/plugin.go` 实现 `core.Plugin`,`Apply` 以 `ctx.Router().Group(api_prefix)`
|
||||
声明根级与 `/v1` 全部路由;`router.Serve` 已删除,装配根改为
|
||||
`core.App` + `driver_http.New(WithEngine(router.BuildEngine()))`,监听与优雅退出归内核。
|
||||
路由保真由 `plugin_parity_test.go` 对拍 `baseline/routes-engine.txt`(256 条方法+路径)保证。
|
||||
- 待办:`platform/bootstrap` 的任务、设置与迁移注册迁入 `Apply`;各业务域按插件标准
|
||||
分层规范收敛到 `handler/ service/ repository/ model/ errs/ migrations/`(模式 2);
|
||||
引擎级中间件、NoRoute 前端兜底与白名单生效三项能力回流上游后,去掉
|
||||
`BuildEngine` 交给 `WithEngine` 这一例外。
|
||||
@@ -0,0 +1,535 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package agent implements the local OpenFlare agent runtime loop.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
agentheartbeat "Wavelet/openflare/plugins/agent/heartbeat"
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
"Wavelet/openflare/plugins/agent/wsclient"
|
||||
edgeheartbeat "Wavelet/openflare/share/edge/heartbeat"
|
||||
"Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
// HeartbeatService handles node registration and periodic heartbeat reporting.
|
||||
type HeartbeatService interface {
|
||||
Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error)
|
||||
Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error)
|
||||
SetToken(token string)
|
||||
}
|
||||
|
||||
// SyncService handles configuration synchronisation between the agent and the server.
|
||||
type SyncService interface {
|
||||
SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
WAFIPGroupChecksums() (map[string]string, error)
|
||||
ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error
|
||||
}
|
||||
|
||||
// RuntimeManager manages the lifecycle and health checks of the OpenResty runtime.
|
||||
type RuntimeManager interface {
|
||||
CheckHealth(ctx context.Context) error
|
||||
Restart(ctx context.Context) error
|
||||
}
|
||||
|
||||
// WebSocketService manages the persistent WebSocket connection to the server.
|
||||
type WebSocketService interface {
|
||||
Connect(ctx context.Context) (protocol.WebSocketConnection, error)
|
||||
SetToken(token string)
|
||||
URL() string
|
||||
}
|
||||
|
||||
const websocketBackoffDefaultDelay = 30 * time.Second
|
||||
|
||||
// Runner coordinates the agent's heartbeat, configuration sync, and WebSocket upgrade lifecycle.
|
||||
type Runner struct {
|
||||
Config *config.Config
|
||||
StateStore *state.Store
|
||||
HeartbeatCycle *agentheartbeat.Cycle
|
||||
HeartbeatService HeartbeatService
|
||||
SyncService SyncService
|
||||
RuntimeManager RuntimeManager
|
||||
WebSocketService WebSocketService
|
||||
|
||||
restartOpenrestyNow bool
|
||||
websocketUpgradeEnabled bool
|
||||
}
|
||||
|
||||
// Run starts the agent's main loop, performing heartbeats and upgrading to WebSocket when available.
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
if r.HeartbeatCycle != nil {
|
||||
r.HeartbeatCycle.RecordSyncError = r.recordSyncError
|
||||
}
|
||||
nodeID, err := r.StateStore.EnsureNodeID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("agent runner started", "node_id", nodeID, "node", r.Config.NodeName, "ip", r.Config.NodeIP)
|
||||
r.runStartupAuth(ctx, &nodeID)
|
||||
|
||||
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
|
||||
defer heartbeatTicker.Stop()
|
||||
var wsDone <-chan error
|
||||
wsBackoff := newWebSocketBackoff()
|
||||
nextWSAttempt := time.Now()
|
||||
tryStartWebSocket := func() {
|
||||
if wsDone != nil || !r.shouldUseWebSocket() || time.Now().Before(nextWSAttempt) {
|
||||
return
|
||||
}
|
||||
done, startErr := r.startWebSocket(ctx, nodeID)
|
||||
if startErr != nil {
|
||||
delay := wsBackoff.Next()
|
||||
nextWSAttempt = time.Now().Add(delay)
|
||||
slog.Debug("agent ws upgrade failed; falling back to http heartbeat",
|
||||
"enabled", r.websocketUpgradeEnabled,
|
||||
"url", r.websocketURL(),
|
||||
"retry_after", delay,
|
||||
"error", startErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
wsBackoff.Reset()
|
||||
wsDone = done
|
||||
slog.Debug("agent switched to websocket mode", "url", r.websocketURL())
|
||||
}
|
||||
tryStartWebSocket()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("agent runner shutting down", "error", ctx.Err())
|
||||
return ctx.Err()
|
||||
case wsErr := <-wsDone:
|
||||
wsDone = nil
|
||||
delay := wsBackoff.Next()
|
||||
nextWSAttempt = time.Now().Add(delay)
|
||||
slog.Debug("agent ws disconnected; resuming http heartbeat", "retry_after", delay, "error", wsErr)
|
||||
r.handleWSDisconnect(ctx, nodeID)
|
||||
case <-heartbeatTicker.C:
|
||||
if wsDone != nil {
|
||||
continue
|
||||
}
|
||||
r.handleHeartbeatTick(ctx, &nodeID, heartbeatTicker, tryStartWebSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) runStartupAuth(ctx context.Context, nodeID *string) {
|
||||
if r.hasAccessToken() {
|
||||
if _, hbErr := r.performHeartbeatCycle(ctx, *nodeID, true); hbErr != nil {
|
||||
slog.Error("agent startup heartbeat failed", "error", hbErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := r.tryRegister(ctx, nodeID); err != nil {
|
||||
slog.Error("agent initial discovery register failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) handleWSDisconnect(ctx context.Context, nodeID string) {
|
||||
if !r.hasAccessToken() {
|
||||
return
|
||||
}
|
||||
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil {
|
||||
slog.Error("agent heartbeat after ws disconnect failed", "error", hbErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) handleHeartbeatTick(ctx context.Context, nodeID *string, heartbeatTicker *time.Ticker, tryStartWebSocket func()) {
|
||||
if !r.hasAccessToken() {
|
||||
if err := r.tryRegister(ctx, nodeID); err != nil {
|
||||
slog.Error("agent discovery register failed", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
changed, hbErr := r.performHeartbeatCycle(ctx, *nodeID, false)
|
||||
if hbErr != nil {
|
||||
slog.Error("agent heartbeat failed", "error", hbErr)
|
||||
return
|
||||
}
|
||||
if changed {
|
||||
heartbeatTicker.Reset(r.Config.HeartbeatInterval.Duration())
|
||||
}
|
||||
tryStartWebSocket()
|
||||
}
|
||||
|
||||
func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, startup bool) (bool, error) {
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
return r.HeartbeatCycle.Perform(ctx, nodeID, startup, r)
|
||||
}
|
||||
|
||||
// Apply applies the provided agent settings and reports whether the heartbeat interval changed.
|
||||
func (r *Runner) Apply(settings *protocol.AgentSettings) bool {
|
||||
return r.applySettings(settings)
|
||||
}
|
||||
|
||||
// RestartOpenrestyIfNeeded restarts OpenResty when a server-requested restart is pending.
|
||||
func (r *Runner) RestartOpenrestyIfNeeded(ctx context.Context) {
|
||||
r.tryRestartOpenresty(ctx)
|
||||
}
|
||||
|
||||
func (r *Runner) shouldUseWebSocket() bool {
|
||||
enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAccessToken()
|
||||
slog.Debug("agent ws upgrade eligibility checked", "enabled", enabled, "server_enabled", r.websocketUpgradeEnabled, "url", r.websocketURL())
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (r *Runner) websocketURL() string {
|
||||
if r.WebSocketService == nil {
|
||||
return ""
|
||||
}
|
||||
return r.WebSocketService.URL()
|
||||
}
|
||||
|
||||
func (r *Runner) startWebSocket(ctx context.Context, nodeID string) (<-chan error, error) {
|
||||
if r.WebSocketService == nil {
|
||||
return nil, errors.New("websocket service is not configured")
|
||||
}
|
||||
conn, err := r.WebSocketService.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
util.Go(func() {
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
done <- r.runWebSocket(ctx, nodeID, conn)
|
||||
})
|
||||
return done, nil
|
||||
}
|
||||
|
||||
type agentWSHandler struct {
|
||||
runner *Runner
|
||||
conn protocol.WebSocketConnection
|
||||
nodeID string
|
||||
statusTicker *time.Ticker
|
||||
}
|
||||
|
||||
func (h *agentWSHandler) OnConnect(ctx context.Context) error {
|
||||
return h.runner.sendWebSocketStatus(ctx, h.nodeID, h.conn)
|
||||
}
|
||||
|
||||
func (h *agentWSHandler) HandleMessage(ctx context.Context, msg wsclient.WSMessage) error {
|
||||
var payloadBytes []byte
|
||||
if msg.Payload != nil {
|
||||
payloadBytes = []byte(msg.Payload)
|
||||
}
|
||||
protoMsg := protocol.WSMessage{
|
||||
Type: msg.Type,
|
||||
Payload: payloadBytes,
|
||||
}
|
||||
changed, err := h.runner.handleWebSocketMessage(ctx, protoMsg, h.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
h.statusTicker.Reset(h.runner.Config.HeartbeatInterval.Duration())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *agentWSHandler) OnClose(err error) {
|
||||
slog.Error("agent ws receive failed", "error", err)
|
||||
}
|
||||
|
||||
func (r *Runner) runWebSocket(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
|
||||
slog.Debug("agent ws connected", "url", conn.URL(), "node_id", nodeID)
|
||||
statusTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
|
||||
defer statusTicker.Stop()
|
||||
|
||||
childCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
util.Go(func() {
|
||||
for {
|
||||
select {
|
||||
case <-childCtx.Done():
|
||||
return
|
||||
case <-statusTicker.C:
|
||||
if err := r.sendWebSocketStatus(childCtx, nodeID, conn); err != nil {
|
||||
slog.Error("agent ws send status failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wsConn, ok := conn.(*wsclient.Connection)
|
||||
if !ok {
|
||||
return errors.New("invalid websocket connection type")
|
||||
}
|
||||
|
||||
return wsConn.RunReceiveLoop(childCtx, &agentWSHandler{
|
||||
runner: r,
|
||||
conn: conn,
|
||||
nodeID: nodeID,
|
||||
statusTicker: statusTicker,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Runner) sendWebSocketStatus(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
payload, ackWindows := r.HeartbeatCycle.PrepareHeartbeatPayload(ctx, nodeID)
|
||||
if err := conn.SendStatus(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
r.HeartbeatCycle.AckObservabilityWindows(ackWindows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WSMessage, conn protocol.WebSocketConnection) (bool, error) {
|
||||
switch message.Type {
|
||||
case protocol.WSMessageTypeSettings:
|
||||
var settings protocol.AgentSettings
|
||||
if err := json.Unmarshal(message.Payload, &settings); err != nil {
|
||||
slog.Debug("agent ws settings decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
changed := r.applySettings(&settings)
|
||||
r.tryRestartOpenresty(ctx)
|
||||
edgeheartbeat.TryAutoUpdate(ctx, r.HeartbeatCycle.Updater, agentheartbeat.AgentSettingsToAutoUpdate(&settings), "agent")
|
||||
if !r.websocketUpgradeEnabled {
|
||||
slog.Debug("agent ws disabled by server settings; falling back to http heartbeat")
|
||||
return changed, errors.New("websocket upgrade disabled by server")
|
||||
}
|
||||
return changed, nil
|
||||
case protocol.WSMessageTypeActiveConfig:
|
||||
var target protocol.ActiveConfigMeta
|
||||
if err := json.Unmarshal(message.Payload, &target); err != nil {
|
||||
slog.Debug("agent ws active config decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("agent ws active config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
|
||||
if err := r.SyncService.SyncOnce(ctx, &target); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent ws triggered sync failed", "version", target.Version, "error", err)
|
||||
}
|
||||
return false, nil
|
||||
case protocol.WSMessageTypeForceSyncConfig:
|
||||
var target protocol.ActiveConfigMeta
|
||||
if err := json.Unmarshal(message.Payload, &target); err != nil {
|
||||
slog.Debug("agent ws force sync config decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("agent ws force sync config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
|
||||
if err := r.SyncService.ForceSyncOnce(ctx, &target); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent ws triggered force sync failed", "version", target.Version, "error", err)
|
||||
}
|
||||
return false, nil
|
||||
case protocol.WSMessageTypeWAFIPGroups:
|
||||
var groups []protocol.WAFIPGroup
|
||||
if err := json.Unmarshal(message.Payload, &groups); err != nil {
|
||||
slog.Debug("agent ws waf ip groups decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
r.HeartbeatCycle.ApplyWAFIPGroups(ctx, groups)
|
||||
return false, nil
|
||||
case protocol.WSMessageTypePing:
|
||||
slog.Debug("agent ws ping received")
|
||||
return false, conn.SendPong()
|
||||
case protocol.WSMessageTypePong:
|
||||
slog.Debug("agent ws pong received")
|
||||
return false, nil
|
||||
default:
|
||||
slog.Debug("agent ws unsupported message type", "type", message.Type)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
type webSocketBackoff struct {
|
||||
delays []time.Duration
|
||||
index int
|
||||
}
|
||||
|
||||
func newWebSocketBackoff() *webSocketBackoff {
|
||||
return &webSocketBackoff{
|
||||
delays: []time.Duration{
|
||||
time.Second,
|
||||
2 * time.Second,
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (backoff *webSocketBackoff) Next() time.Duration {
|
||||
if backoff == nil || len(backoff.delays) == 0 {
|
||||
return websocketBackoffDefaultDelay
|
||||
}
|
||||
if backoff.index >= len(backoff.delays) {
|
||||
return backoff.delays[len(backoff.delays)-1]
|
||||
}
|
||||
delay := backoff.delays[backoff.index]
|
||||
backoff.index++
|
||||
return delay
|
||||
}
|
||||
|
||||
func (backoff *webSocketBackoff) Reset() {
|
||||
if backoff != nil {
|
||||
backoff.index = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) hasAccessToken() bool {
|
||||
return strings.TrimSpace(r.Config.AccessToken) != ""
|
||||
}
|
||||
|
||||
func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
|
||||
if settings == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if settings.HeartbeatInterval > 0 {
|
||||
newInterval := config.MillisecondDuration(time.Duration(settings.HeartbeatInterval) * time.Millisecond)
|
||||
if newInterval != r.Config.HeartbeatInterval {
|
||||
slog.Info("agent heartbeat interval updated", "from", r.Config.HeartbeatInterval, "to", newInterval)
|
||||
r.Config.HeartbeatInterval = newInterval
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if settings.WebsocketUpgradeEnabled != r.websocketUpgradeEnabled {
|
||||
slog.Debug("agent websocket upgrade setting updated", "from", r.websocketUpgradeEnabled, "to", settings.WebsocketUpgradeEnabled)
|
||||
}
|
||||
r.websocketUpgradeEnabled = settings.WebsocketUpgradeEnabled
|
||||
r.restartOpenrestyNow = settings.RestartOpenrestyNow
|
||||
return changed
|
||||
}
|
||||
|
||||
func (r *Runner) tryRestartOpenresty(ctx context.Context) {
|
||||
if !r.restartOpenrestyNow {
|
||||
return
|
||||
}
|
||||
r.restartOpenrestyNow = false
|
||||
if r.RuntimeManager == nil {
|
||||
return
|
||||
}
|
||||
slog.Info("agent openresty restart requested by server")
|
||||
if err := r.RuntimeManager.Restart(ctx); err != nil {
|
||||
slog.Error("agent openresty restart failed", "error", err)
|
||||
r.recordOpenrestyUnhealthy(err, false)
|
||||
return
|
||||
}
|
||||
slog.Info("agent openresty restart succeeded")
|
||||
r.recordOpenrestyHealthy()
|
||||
}
|
||||
|
||||
func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
|
||||
if strings.TrimSpace(r.Config.DiscoveryToken) == "" {
|
||||
return errors.New("agent_token 为空且未配置 discovery_token")
|
||||
}
|
||||
slog.Info("agent discovery registration started")
|
||||
response, err := r.HeartbeatService.Register(ctx, r.HeartbeatCycle.NodePayload(ctx, *nodeID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response == nil || strings.TrimSpace(response.AccessToken) == "" || strings.TrimSpace(response.NodeID) == "" {
|
||||
return errors.New("discovery register response 缺少 node_id 或 agent_token")
|
||||
}
|
||||
snapshot, err := r.StateStore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot.NodeID = response.NodeID
|
||||
if err = r.StateStore.Save(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Config.AccessToken = response.AccessToken
|
||||
r.Config.DiscoveryToken = ""
|
||||
if err = r.Config.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.HeartbeatService.SetToken(response.AccessToken)
|
||||
if r.WebSocketService != nil {
|
||||
r.WebSocketService.SetToken(response.AccessToken)
|
||||
}
|
||||
*nodeID = response.NodeID
|
||||
slog.Info("agent discovery registration succeeded", "node_id", response.NodeID)
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
if _, err = r.HeartbeatCycle.Perform(ctx, *nodeID, true, r); err != nil {
|
||||
slog.Error("agent post-register heartbeat failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) recordSyncError(err error) {
|
||||
if err == nil || r.StateStore == nil {
|
||||
return
|
||||
}
|
||||
snapshot, loadErr := r.StateStore.Load()
|
||||
if loadErr != nil {
|
||||
slog.Error("load state before recording sync error failed", "error", loadErr)
|
||||
return
|
||||
}
|
||||
snapshot.LastError = err.Error()
|
||||
slog.Warn("recording sync error into state", "error", snapshot.LastError)
|
||||
if saveErr := r.StateStore.Save(snapshot); saveErr != nil {
|
||||
slog.Error("save state after sync error failed", "error", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) refreshOpenrestyHealth(ctx context.Context) {
|
||||
if r.RuntimeManager == nil || r.StateStore == nil {
|
||||
return
|
||||
}
|
||||
if err := r.RuntimeManager.CheckHealth(ctx); err != nil {
|
||||
if strings.Contains(err.Error(), "openresty config not exists") {
|
||||
return
|
||||
}
|
||||
r.recordOpenrestyUnhealthy(err, true)
|
||||
return
|
||||
}
|
||||
r.recordOpenrestyHealthy()
|
||||
}
|
||||
|
||||
func (r *Runner) recordOpenrestyHealthy() {
|
||||
if r.StateStore == nil {
|
||||
return
|
||||
}
|
||||
snapshot, err := r.StateStore.Load()
|
||||
if err != nil {
|
||||
slog.Error("load state before recording openresty health failed", "error", err)
|
||||
return
|
||||
}
|
||||
if snapshot.OpenrestyStatus == protocol.OpenrestyStatusHealthy && strings.TrimSpace(snapshot.OpenrestyMessage) == "" {
|
||||
return
|
||||
}
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = ""
|
||||
if err = r.StateStore.Save(snapshot); err != nil {
|
||||
slog.Error("save state after recording openresty health failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) recordOpenrestyUnhealthy(err error, fallbackOnly bool) {
|
||||
if err == nil || r.StateStore == nil {
|
||||
return
|
||||
}
|
||||
snapshot, loadErr := r.StateStore.Load()
|
||||
if loadErr != nil {
|
||||
slog.Error("load state before recording openresty error failed", "error", loadErr)
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(err.Error())
|
||||
if !fallbackOnly || strings.TrimSpace(snapshot.OpenrestyMessage) == "" {
|
||||
snapshot.OpenrestyMessage = message
|
||||
}
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
if saveErr := r.StateStore.Save(snapshot); saveErr != nil {
|
||||
slog.Error("save state after recording openresty error failed", "error", saveErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
agentheartbeat "Wavelet/openflare/plugins/agent/heartbeat"
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
"Wavelet/openflare/plugins/agent/updater"
|
||||
)
|
||||
|
||||
func withHeartbeatCycle(runner *Runner, observabilityBuffer *state.ObservabilityBufferStore) *Runner {
|
||||
runner.HeartbeatCycle = &agentheartbeat.Cycle{
|
||||
Config: runner.Config,
|
||||
StateStore: runner.StateStore,
|
||||
ObservabilityBuffer: observabilityBuffer,
|
||||
Heartbeat: runner.HeartbeatService,
|
||||
Sync: runner.SyncService,
|
||||
Updater: updater.New(),
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
type fakeHeartbeatService struct {
|
||||
mu sync.Mutex
|
||||
registerCalls int
|
||||
heartbeatCalls int
|
||||
registerErr error
|
||||
registerResp *protocol.RegisterNodeResponse
|
||||
heartbeatErrs []error
|
||||
heartbeatResults []*protocol.HeartbeatResult
|
||||
heartbeatPayloads []protocol.NodePayload
|
||||
onHeartbeat func(int)
|
||||
lastToken string
|
||||
}
|
||||
|
||||
func (f *fakeHeartbeatService) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.registerCalls++
|
||||
return f.registerResp, f.registerErr
|
||||
}
|
||||
|
||||
func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) {
|
||||
f.mu.Lock()
|
||||
f.heartbeatCalls++
|
||||
callIndex := f.heartbeatCalls
|
||||
f.heartbeatPayloads = append(f.heartbeatPayloads, payload)
|
||||
var err error
|
||||
if len(f.heartbeatErrs) >= callIndex {
|
||||
err = f.heartbeatErrs[callIndex-1]
|
||||
}
|
||||
var result *protocol.HeartbeatResult
|
||||
if len(f.heartbeatResults) >= callIndex {
|
||||
result = f.heartbeatResults[callIndex-1]
|
||||
}
|
||||
onHeartbeat := f.onHeartbeat
|
||||
f.mu.Unlock()
|
||||
if onHeartbeat != nil {
|
||||
onHeartbeat(callIndex)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (f *fakeHeartbeatService) SetToken(token string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastToken = token
|
||||
}
|
||||
|
||||
type fakeSyncService struct {
|
||||
mu sync.Mutex
|
||||
startupErr error
|
||||
syncOnceErr error
|
||||
startupCalls int
|
||||
syncOnceCalls int
|
||||
lastTarget *protocol.ActiveConfigMeta
|
||||
onSyncOnceCall func(int)
|
||||
wafChecksums map[string]string
|
||||
wafGroups []protocol.WAFIPGroup
|
||||
}
|
||||
|
||||
type fakeRuntimeManager struct {
|
||||
mu sync.Mutex
|
||||
healthErr error
|
||||
restartErr error
|
||||
restartCalls int
|
||||
clearHealthOnRestart bool
|
||||
}
|
||||
|
||||
func (f *fakeRuntimeManager) CheckHealth(ctx context.Context) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.healthErr
|
||||
}
|
||||
|
||||
func (f *fakeRuntimeManager) Restart(ctx context.Context) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.restartCalls++
|
||||
if f.clearHealthOnRestart && f.restartErr == nil {
|
||||
f.healthErr = nil
|
||||
}
|
||||
return f.restartErr
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.startupCalls++
|
||||
return f.startupErr
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
f.mu.Lock()
|
||||
f.syncOnceCalls++
|
||||
if target != nil {
|
||||
copied := *target
|
||||
f.lastTarget = &copied
|
||||
}
|
||||
callIndex := f.syncOnceCalls
|
||||
callback := f.onSyncOnceCall
|
||||
f.mu.Unlock()
|
||||
if callback != nil {
|
||||
callback(callIndex)
|
||||
}
|
||||
return f.syncOnceErr
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
f.mu.Lock()
|
||||
f.syncOnceCalls++
|
||||
if target != nil {
|
||||
copied := *target
|
||||
f.lastTarget = &copied
|
||||
}
|
||||
callIndex := f.syncOnceCalls
|
||||
callback := f.onSyncOnceCall
|
||||
f.mu.Unlock()
|
||||
if callback != nil {
|
||||
callback(callIndex)
|
||||
}
|
||||
return f.syncOnceErr
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
if f.wafChecksums == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return f.wafChecksums, nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.wafGroups = append(f.wafGroups, groups...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeWebSocketConnection struct {
|
||||
pongCalls int
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) URL() string {
|
||||
return "ws://127.0.0.1/api/v1/agent/ws"
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) SendStatus(payload protocol.NodePayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) SendPong() error {
|
||||
f.pongCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) Receive() (protocol.WSMessage, error) {
|
||||
return protocol.WSMessage{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
heartbeatService := &fakeHeartbeatService{
|
||||
heartbeatResults: []*protocol.HeartbeatResult{{}},
|
||||
onHeartbeat: func(callCount int) {
|
||||
if callCount >= 2 {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
syncService := &fakeSyncService{
|
||||
startupErr: errors.New("当前没有激活版本,保持当前 OpenResty 配置"),
|
||||
}
|
||||
runner := withHeartbeatCycle(&Runner{
|
||||
Config: &config.Config{
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-01",
|
||||
NodeIP: "10.0.0.8",
|
||||
NodeIPConfigured: true,
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
HeartbeatService: heartbeatService,
|
||||
SyncService: syncService,
|
||||
}, nil)
|
||||
|
||||
err := runner.Run(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context cancellation, got %v", err)
|
||||
}
|
||||
if heartbeatService.registerCalls != 0 {
|
||||
t.Fatalf("expected no discovery register call, got %d", heartbeatService.registerCalls)
|
||||
}
|
||||
if heartbeatService.heartbeatCalls < 2 {
|
||||
t.Fatalf("expected heartbeat loop to continue, got %d heartbeat calls", heartbeatService.heartbeatCalls)
|
||||
}
|
||||
snapshot, loadErr := stateStore.Load()
|
||||
if loadErr != nil {
|
||||
t.Fatalf("failed to load state: %v", loadErr)
|
||||
}
|
||||
if snapshot.LastError != "当前没有激活版本,保持当前 OpenResty 配置" {
|
||||
t.Fatalf("expected startup sync error to be recorded, got %q", snapshot.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
heartbeatService := &fakeHeartbeatService{
|
||||
registerErr: errors.New("register timeout"),
|
||||
heartbeatErrs: []error{errors.New("heartbeat timeout")},
|
||||
heartbeatResults: []*protocol.HeartbeatResult{
|
||||
{},
|
||||
},
|
||||
}
|
||||
syncService := &fakeSyncService{
|
||||
syncOnceErr: errors.New("openresty reload failed"),
|
||||
onSyncOnceCall: func(callCount int) {
|
||||
if callCount >= 1 {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
runner := withHeartbeatCycle(&Runner{
|
||||
Config: &config.Config{
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-01",
|
||||
NodeIP: "10.0.0.8",
|
||||
NodeIPConfigured: true,
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
HeartbeatService: heartbeatService,
|
||||
SyncService: syncService,
|
||||
}, nil)
|
||||
|
||||
err := runner.Run(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context cancellation, got %v", err)
|
||||
}
|
||||
if heartbeatService.registerCalls != 0 {
|
||||
t.Fatalf("expected no register attempt, got %d", heartbeatService.registerCalls)
|
||||
}
|
||||
if syncService.syncOnceCalls == 0 {
|
||||
t.Fatal("expected sync loop to continue after heartbeat/register errors")
|
||||
}
|
||||
snapshot, loadErr := stateStore.Load()
|
||||
if loadErr != nil {
|
||||
t.Fatalf("failed to load state: %v", loadErr)
|
||||
}
|
||||
if snapshot.LastError != "openresty reload failed" {
|
||||
t.Fatalf("expected sync error to be recorded, got %q", snapshot.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
if err := stateStore.Save(&state.Snapshot{
|
||||
OpenrestyStatus: protocol.OpenrestyStatusUnhealthy,
|
||||
OpenrestyMessage: "docker run openresty failed: bind 80 already allocated",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to seed state: %v", err)
|
||||
}
|
||||
heartbeatService := &fakeHeartbeatService{
|
||||
heartbeatResults: []*protocol.HeartbeatResult{{
|
||||
AgentSettings: &protocol.AgentSettings{RestartOpenrestyNow: true},
|
||||
}},
|
||||
onHeartbeat: func(callCount int) {
|
||||
if callCount >= 1 {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
runtimeManager := &fakeRuntimeManager{
|
||||
healthErr: errors.New("docker openresty container is not running"),
|
||||
clearHealthOnRestart: true,
|
||||
}
|
||||
runner := withHeartbeatCycle(&Runner{
|
||||
Config: &config.Config{
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-01",
|
||||
NodeIP: "10.0.0.8",
|
||||
NodeIPConfigured: true,
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
HeartbeatService: heartbeatService,
|
||||
SyncService: &fakeSyncService{},
|
||||
RuntimeManager: runtimeManager,
|
||||
}, nil)
|
||||
|
||||
err := runner.Run(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context cancellation, got %v", err)
|
||||
}
|
||||
if len(heartbeatService.heartbeatPayloads) == 0 {
|
||||
t.Fatal("expected at least one heartbeat payload")
|
||||
}
|
||||
payload := heartbeatService.heartbeatPayloads[0]
|
||||
if payload.OpenrestyStatus != protocol.OpenrestyStatusUnhealthy {
|
||||
t.Fatalf("expected unhealthy openresty status in heartbeat payload, got %q", payload.OpenrestyStatus)
|
||||
}
|
||||
if payload.OpenrestyMessage != "docker run openresty failed: bind 80 already allocated" {
|
||||
t.Fatalf("unexpected openresty message: %q", payload.OpenrestyMessage)
|
||||
}
|
||||
if runtimeManager.restartCalls != 1 {
|
||||
t.Fatalf("expected one openresty restart attempt, got %d", runtimeManager.restartCalls)
|
||||
}
|
||||
snapshot, loadErr := stateStore.Load()
|
||||
if loadErr != nil {
|
||||
t.Fatalf("failed to load state: %v", loadErr)
|
||||
}
|
||||
if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy || snapshot.OpenrestyMessage != "" {
|
||||
t.Fatal("expected restart success to mark openresty healthy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
if err := stateStore.Save(&state.Snapshot{
|
||||
NodeID: "node-observe",
|
||||
CurrentVersion: "20260314-001",
|
||||
LastError: "sync failed",
|
||||
OpenrestyStatus: protocol.OpenrestyStatusUnhealthy,
|
||||
OpenrestyMessage: "reload failed",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to seed state: %v", err)
|
||||
}
|
||||
|
||||
runner := withHeartbeatCycle(&Runner{
|
||||
Config: &config.Config{
|
||||
NodeName: "edge-observe-1",
|
||||
NodeIP: "10.0.0.51",
|
||||
NodeIPConfigured: true,
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
DataDir: tempDir,
|
||||
RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"),
|
||||
AccessLogPath: filepath.Join(tempDir, "var", "log", "openflare", "access.log"),
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
}, nil)
|
||||
if err := os.MkdirAll(filepath.Dir(runner.Config.AccessLogPath), 0o755); err != nil {
|
||||
t.Fatalf("failed to prepare access log dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
runner.Config.AccessLogPath,
|
||||
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to prepare access log: %v", err)
|
||||
}
|
||||
|
||||
firstPayload := runner.HeartbeatCycle.NodePayload(context.Background(), "node-observe")
|
||||
if firstPayload.Profile == nil {
|
||||
t.Fatal("expected first heartbeat payload to include system profile")
|
||||
}
|
||||
if firstPayload.HostMetrics == nil {
|
||||
t.Fatal("expected first heartbeat payload to include host metrics")
|
||||
}
|
||||
if firstPayload.SchemaVersion != 2 {
|
||||
t.Fatalf("expected schema_version 2, got %d", firstPayload.SchemaVersion)
|
||||
}
|
||||
if len(firstPayload.AccessLogs) != 1 || firstPayload.AccessLogs[0].Path != "/" {
|
||||
t.Fatalf("expected first heartbeat payload to include access logs, got %+v", firstPayload.AccessLogs)
|
||||
}
|
||||
if len(firstPayload.HealthEvents) != 2 {
|
||||
t.Fatalf("expected health events for openresty and sync error, got %+v", firstPayload.HealthEvents)
|
||||
}
|
||||
|
||||
secondPayload := runner.HeartbeatCycle.NodePayload(context.Background(), "node-observe")
|
||||
if secondPayload.Profile != nil {
|
||||
t.Fatal("expected unchanged profile to be omitted on subsequent heartbeat")
|
||||
}
|
||||
if secondPayload.HostMetrics == nil {
|
||||
t.Fatal("expected host metrics to continue reporting on subsequent heartbeat")
|
||||
}
|
||||
if len(secondPayload.AccessLogs) != 0 {
|
||||
t.Fatalf("expected unchanged access log delta to be omitted on subsequent heartbeat, got %+v", secondPayload.AccessLogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
|
||||
bufferStore := state.NewObservabilityBufferStore(filepath.Join(tempDir, "observability-buffer.json"))
|
||||
nowUnix := time.Now().UTC().Unix()
|
||||
bufferWindow := nowUnix - (nowUnix % 60) - 60
|
||||
if err := bufferStore.Upsert(state.ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: bufferWindow,
|
||||
HostMetrics: &protocol.NodeMetricSnapshot{CapturedAtUnix: bufferWindow + 5, CPUUsagePercent: 30},
|
||||
EdgeHealth: &protocol.NodeEdgeHealth{CapturedAtUnix: bufferWindow + 5, Connections: 3, Status: "healthy"},
|
||||
QueuedAtUnix: bufferWindow + 60,
|
||||
}, 0); err != nil {
|
||||
t.Fatalf("failed to seed observability buffer: %v", err)
|
||||
}
|
||||
heartbeatService := &fakeHeartbeatService{
|
||||
heartbeatErrs: []error{errors.New("server offline"), nil},
|
||||
heartbeatResults: []*protocol.HeartbeatResult{{}, {}},
|
||||
onHeartbeat: func(callCount int) {
|
||||
if callCount >= 2 {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
runner := withHeartbeatCycle(&Runner{
|
||||
Config: &config.Config{
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-buffer-01",
|
||||
NodeIP: "10.0.0.52",
|
||||
NodeIPConfigured: true,
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
DataDir: tempDir,
|
||||
RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"),
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
ObservabilityReplayMinutes: 15,
|
||||
},
|
||||
StateStore: stateStore,
|
||||
HeartbeatService: heartbeatService,
|
||||
SyncService: &fakeSyncService{},
|
||||
}, bufferStore)
|
||||
if err := os.MkdirAll(filepath.Dir(runner.Config.RouteConfigPath), 0o755); err != nil {
|
||||
t.Fatalf("failed to prepare route config dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "openflare_access.log"),
|
||||
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to prepare access log: %v", err)
|
||||
}
|
||||
|
||||
runErr := runner.Run(ctx)
|
||||
if runErr != context.Canceled {
|
||||
t.Fatalf("expected run to stop by context cancellation, got %v", runErr)
|
||||
}
|
||||
if len(heartbeatService.heartbeatPayloads) != 2 {
|
||||
t.Fatalf("expected two heartbeat payloads, got %d", len(heartbeatService.heartbeatPayloads))
|
||||
}
|
||||
secondPayload := heartbeatService.heartbeatPayloads[1]
|
||||
if len(secondPayload.Buffered) != 1 {
|
||||
t.Fatalf("expected second heartbeat to replay one buffered observation, got %+v", secondPayload.Buffered)
|
||||
}
|
||||
if len(secondPayload.Buffered[0].AccessLogs) != 0 {
|
||||
t.Fatalf("expected seeded buffered observation to keep empty access logs, got %+v", secondPayload.Buffered[0].AccessLogs)
|
||||
}
|
||||
if secondPayload.Buffered[0].EdgeHealth == nil || secondPayload.Buffered[0].EdgeHealth.Connections != 3 {
|
||||
t.Fatalf("expected buffered edge health, got %+v", secondPayload.Buffered[0].EdgeHealth)
|
||||
}
|
||||
|
||||
replayable, err := bufferStore.Replayable(0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable after recovery failed: %v", err)
|
||||
}
|
||||
if len(replayable) != 0 {
|
||||
t.Fatalf("expected buffer to be acked after successful heartbeat, got %+v", replayable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
heartbeatService := &fakeHeartbeatService{
|
||||
registerResp: &protocol.RegisterNodeResponse{
|
||||
NodeID: "node-server-assigned",
|
||||
AccessToken: "agent-token-issued",
|
||||
Name: "edge-01",
|
||||
},
|
||||
heartbeatResults: []*protocol.HeartbeatResult{{}},
|
||||
onHeartbeat: func(callCount int) {
|
||||
if callCount >= 1 {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
syncService := &fakeSyncService{}
|
||||
configPath := filepath.Join(t.TempDir(), "agent.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://127.0.0.1:3000","discovery_token":"discovery-token","node_name":"edge-01","node_ip":"10.0.0.8"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to seed config file: %v", err)
|
||||
}
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
runner := withHeartbeatCycle(&Runner{
|
||||
Config: &config.Config{
|
||||
ServerURL: cfg.ServerURL,
|
||||
DiscoveryToken: cfg.DiscoveryToken,
|
||||
NodeName: cfg.NodeName,
|
||||
NodeIP: cfg.NodeIP,
|
||||
NodeIPConfigured: cfg.NodeIPConfigured,
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
HeartbeatService: heartbeatService,
|
||||
SyncService: syncService,
|
||||
}, nil)
|
||||
runner.Config = cfg
|
||||
runner.Config.Version = config.Version
|
||||
runner.Config.ExtVersion = "1.27.1.2"
|
||||
runner.Config.HeartbeatInterval = config.MillisecondDuration(10 * time.Millisecond)
|
||||
|
||||
err = runner.Run(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context cancellation, got %v", err)
|
||||
}
|
||||
if heartbeatService.registerCalls == 0 {
|
||||
t.Fatal("expected discovery register to be attempted")
|
||||
}
|
||||
if heartbeatService.lastToken != "agent-token-issued" {
|
||||
t.Fatalf("expected client token to be updated, got %q", heartbeatService.lastToken)
|
||||
}
|
||||
snapshot, loadErr := stateStore.Load()
|
||||
if loadErr != nil {
|
||||
t.Fatalf("failed to load state: %v", loadErr)
|
||||
}
|
||||
if snapshot.NodeID != "node-server-assigned" {
|
||||
t.Fatalf("expected node id to be replaced, got %q", snapshot.NodeID)
|
||||
}
|
||||
if runner.Config.AccessToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" {
|
||||
t.Fatal("expected config token rotation to complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerHandlesWebSocketActiveConfigMessage(t *testing.T) {
|
||||
syncService := &fakeSyncService{}
|
||||
runner := withHeartbeatCycle(&Runner{SyncService: syncService}, nil)
|
||||
payload, err := json.Marshal(protocol.ActiveConfigMeta{
|
||||
Version: "20260529-001",
|
||||
Checksum: "checksum-ws",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal active config: %v", err)
|
||||
}
|
||||
|
||||
changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{
|
||||
Type: protocol.WSMessageTypeActiveConfig,
|
||||
Payload: payload,
|
||||
}, &fakeWebSocketConnection{})
|
||||
if err != nil {
|
||||
t.Fatalf("handle websocket active config: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatal("active config message should not change heartbeat interval")
|
||||
}
|
||||
if syncService.syncOnceCalls != 1 {
|
||||
t.Fatalf("expected one sync call, got %d", syncService.syncOnceCalls)
|
||||
}
|
||||
if syncService.lastTarget == nil || syncService.lastTarget.Version != "20260529-001" || syncService.lastTarget.Checksum != "checksum-ws" {
|
||||
t.Fatalf("unexpected sync target: %+v", syncService.lastTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerHandlesWebSocketSettingsDisabled(t *testing.T) {
|
||||
runner := withHeartbeatCycle(&Runner{
|
||||
Config: &config.Config{
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Second),
|
||||
},
|
||||
websocketUpgradeEnabled: true,
|
||||
}, nil)
|
||||
payload, err := json.Marshal(protocol.AgentSettings{
|
||||
HeartbeatInterval: 15000,
|
||||
WebsocketUpgradeEnabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
|
||||
changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{
|
||||
Type: protocol.WSMessageTypeSettings,
|
||||
Payload: payload,
|
||||
}, &fakeWebSocketConnection{})
|
||||
if err == nil {
|
||||
t.Fatal("expected disabled websocket setting to request fallback")
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected heartbeat interval change to be reported")
|
||||
}
|
||||
if runner.websocketUpgradeEnabled {
|
||||
t.Fatal("expected websocket upgrade to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketBackoffSequence(t *testing.T) {
|
||||
backoff := newWebSocketBackoff()
|
||||
expected := []time.Duration{
|
||||
time.Second,
|
||||
2 * time.Second,
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
30 * time.Second,
|
||||
30 * time.Second,
|
||||
}
|
||||
for _, want := range expected {
|
||||
if got := backoff.Next(); got != want {
|
||||
t.Fatalf("unexpected backoff: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
backoff.Reset()
|
||||
if got := backoff.Next(); got != time.Second {
|
||||
t.Fatalf("expected reset backoff to return 1s, got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config loads and persists agent daemon configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/share/edge/nodeip"
|
||||
"Wavelet/openflare/share/ofutil"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMainConfigRelativePath = "etc/nginx/nginx.conf"
|
||||
defaultRouteConfigRelativePath = "etc/nginx/conf.d/openflare_routes.conf"
|
||||
defaultCertDirRelativePath = "etc/nginx/certs"
|
||||
defaultLuaDirRelativePath = "etc/nginx/lua"
|
||||
defaultRuntimeConfigDirRelativePath = "etc/openflare"
|
||||
defaultPagesDirRelativePath = "var/lib/openflare/pages"
|
||||
defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb"
|
||||
defaultCityMMDBRelativePath = "etc/openflare/GeoLite2-City.mmdb"
|
||||
defaultAccessLogRelativePath = "var/log/openflare/access.log"
|
||||
defaultStateRelativePath = "var/lib/openflare/agent-state.json"
|
||||
defaultObservabilityBufferRelativePath = "var/lib/openflare/observability-buffer.json"
|
||||
defaultOpenRestyObservabilityPort = 18081
|
||||
defaultObservabilityReplayMinutes = 60
|
||||
defaultMMDBUpdateInterval = 24 * time.Hour
|
||||
defaultMMDBDownloadURL = "https://github.com/FyraLabs/geolite2/releases/latest/download/GeoLite2-Country.mmdb"
|
||||
defaultCityMMDBDownloadURL = "https://github.com/FyraLabs/geolite2/releases/latest/download/GeoLite2-City.mmdb"
|
||||
defaultHeartbeatInterval = 3 * time.Second
|
||||
defaultRequestTimeout = 10 * time.Second
|
||||
configFilePerm = 0o600
|
||||
)
|
||||
|
||||
// Config holds the full runtime configuration for the OpenFlare agent.
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AccessToken string `json:"agent_token"`
|
||||
DiscoveryToken string `json:"discovery_token"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeIP string `json:"node_ip"`
|
||||
Version string `json:"-"`
|
||||
ExtVersion string `json:"-"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
MainConfigPath string `json:"main_config_path"`
|
||||
RouteConfigPath string `json:"route_config_path"`
|
||||
AccessLogPath string `json:"access_log_path"`
|
||||
CertDir string `json:"cert_dir"`
|
||||
OpenrestyCertDir string `json:"openresty_cert_dir"`
|
||||
LuaDir string `json:"lua_dir"`
|
||||
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
||||
RuntimeConfigDir string `json:"runtime_config_dir"`
|
||||
PagesDir string `json:"pages_dir"`
|
||||
MMDBPath string `json:"mmdb_path"`
|
||||
CityMMDBPath string `json:"city_mmdb_path"`
|
||||
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
||||
MMDBDownloadURL string `json:"mmdb_download_url"`
|
||||
CityMMDBDownloadURL string `json:"city_mmdb_download_url"`
|
||||
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
|
||||
ObservabilityBufferPath string `json:"observability_buffer_path"`
|
||||
ObservabilityReplayMinutes int `json:"observability_replay_minutes"`
|
||||
StatePath string `json:"state_path"`
|
||||
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
|
||||
RequestTimeout MillisecondDuration `json:"request_timeout"`
|
||||
configPath string `json:"-"`
|
||||
NodeIPConfigured bool `json:"-"`
|
||||
}
|
||||
|
||||
type configFile struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AccessToken string `json:"agent_token"`
|
||||
DiscoveryToken string `json:"discovery_token"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeIP string `json:"node_ip"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers"`
|
||||
DataDir string `json:"data_dir"`
|
||||
MainConfigPath string `json:"main_config_path"`
|
||||
RouteConfigPath string `json:"route_config_path"`
|
||||
AccessLogPath string `json:"access_log_path"`
|
||||
CertDir string `json:"cert_dir"`
|
||||
OpenrestyCertDir string `json:"openresty_cert_dir"`
|
||||
LuaDir string `json:"lua_dir"`
|
||||
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
||||
RuntimeConfigDir string `json:"runtime_config_dir"`
|
||||
PagesDir string `json:"pages_dir"`
|
||||
MMDBPath string `json:"mmdb_path"`
|
||||
CityMMDBPath string `json:"city_mmdb_path"`
|
||||
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
||||
MMDBDownloadURL string `json:"mmdb_download_url"`
|
||||
CityMMDBDownloadURL string `json:"city_mmdb_download_url"`
|
||||
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
|
||||
ObservabilityBufferPath string `json:"observability_buffer_path"`
|
||||
ObservabilityReplayMinutes int `json:"observability_replay_minutes"`
|
||||
StatePath string `json:"state_path"`
|
||||
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
|
||||
RequestTimeout MillisecondDuration `json:"request_timeout"`
|
||||
}
|
||||
|
||||
func transferPersistedConfig(dst, src any) error {
|
||||
data, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, dst)
|
||||
}
|
||||
|
||||
// Load reads and parses the agent configuration file at the given path.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // path is the configured agent config location
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
file := &configFile{}
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(data, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil && !hasEnvConfig() {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err == nil {
|
||||
if err = transferPersistedConfig(cfg, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cfg.configPath = path
|
||||
applyEnvOverrides(cfg)
|
||||
cfg.NodeIPConfigured = cfg.NodeIP != ""
|
||||
applyDefaults(cfg, filepath.Dir(path))
|
||||
if err = validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
cfg.Version = Version
|
||||
cfg.OpenrestyResolvers = ofutil.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers)
|
||||
applyAgentIdentityDefaults(cfg)
|
||||
applyAgentPathDefaults(cfg, baseDir)
|
||||
applyAgentTimingDefaults(cfg)
|
||||
normalizeManagedPaths(cfg)
|
||||
}
|
||||
|
||||
func applyAgentIdentityDefaults(cfg *Config) {
|
||||
if cfg.OpenrestyPath == "" {
|
||||
cfg.OpenrestyPath = "openresty"
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
cfg.NodeName = detectHostname()
|
||||
}
|
||||
if cfg.NodeIP == "" {
|
||||
cfg.NodeIP = nodeip.Detect()
|
||||
}
|
||||
}
|
||||
|
||||
func applyAgentPathDefaults(cfg *Config, baseDir string) {
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = filepath.Join(baseDir, "data")
|
||||
}
|
||||
type managedPathDefault struct {
|
||||
target *string
|
||||
relative string
|
||||
}
|
||||
pathDefaults := []managedPathDefault{
|
||||
{&cfg.MainConfigPath, defaultMainConfigRelativePath},
|
||||
{&cfg.RouteConfigPath, defaultRouteConfigRelativePath},
|
||||
{&cfg.AccessLogPath, defaultAccessLogRelativePath},
|
||||
{&cfg.StatePath, defaultStateRelativePath},
|
||||
{&cfg.CertDir, defaultCertDirRelativePath},
|
||||
{&cfg.LuaDir, defaultLuaDirRelativePath},
|
||||
{&cfg.RuntimeConfigDir, defaultRuntimeConfigDirRelativePath},
|
||||
{&cfg.PagesDir, defaultPagesDirRelativePath},
|
||||
{&cfg.MMDBPath, defaultMMDBRelativePath},
|
||||
{&cfg.CityMMDBPath, defaultCityMMDBRelativePath},
|
||||
{&cfg.ObservabilityBufferPath, defaultObservabilityBufferRelativePath},
|
||||
}
|
||||
for _, item := range pathDefaults {
|
||||
if strings.TrimSpace(*item.target) == "" {
|
||||
*item.target = joinManagedPath(cfg.DataDir, item.relative)
|
||||
}
|
||||
}
|
||||
if cfg.OpenrestyCertDir == "" {
|
||||
cfg.OpenrestyCertDir = cfg.CertDir
|
||||
}
|
||||
if cfg.OpenrestyLuaDir == "" {
|
||||
cfg.OpenrestyLuaDir = cfg.LuaDir
|
||||
}
|
||||
}
|
||||
|
||||
func applyAgentTimingDefaults(cfg *Config) {
|
||||
if cfg.MMDBUpdateInterval <= 0 {
|
||||
cfg.MMDBUpdateInterval = MillisecondDuration(defaultMMDBUpdateInterval)
|
||||
}
|
||||
if cfg.MMDBDownloadURL == "" {
|
||||
cfg.MMDBDownloadURL = defaultMMDBDownloadURL
|
||||
}
|
||||
if cfg.CityMMDBDownloadURL == "" {
|
||||
cfg.CityMMDBDownloadURL = defaultCityMMDBDownloadURL
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort <= 0 {
|
||||
cfg.OpenrestyObservabilityPort = defaultOpenRestyObservabilityPort
|
||||
}
|
||||
if cfg.ObservabilityReplayMinutes <= 0 {
|
||||
cfg.ObservabilityReplayMinutes = defaultObservabilityReplayMinutes
|
||||
}
|
||||
if cfg.HeartbeatInterval <= 0 {
|
||||
cfg.HeartbeatInterval = MillisecondDuration(defaultHeartbeatInterval)
|
||||
}
|
||||
if cfg.RequestTimeout <= 0 {
|
||||
cfg.RequestTimeout = MillisecondDuration(defaultRequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeManagedPaths(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
paths := []*string{
|
||||
&cfg.DataDir,
|
||||
&cfg.MainConfigPath,
|
||||
&cfg.RouteConfigPath,
|
||||
&cfg.AccessLogPath,
|
||||
&cfg.CertDir,
|
||||
&cfg.OpenrestyCertDir,
|
||||
&cfg.LuaDir,
|
||||
&cfg.OpenrestyLuaDir,
|
||||
&cfg.RuntimeConfigDir,
|
||||
&cfg.PagesDir,
|
||||
&cfg.StatePath,
|
||||
&cfg.ObservabilityBufferPath,
|
||||
&cfg.MMDBPath,
|
||||
&cfg.CityMMDBPath,
|
||||
}
|
||||
for _, p := range paths {
|
||||
if usesSlashPath(*p) {
|
||||
*p = filepath.ToSlash(*p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnvConfig() bool {
|
||||
for _, key := range []string{
|
||||
"OPENFLARE_SERVER_URL",
|
||||
"OPENFLARE_AGENT_TOKEN",
|
||||
"OPENFLARE_DISCOVERY_TOKEN",
|
||||
"OPENFLARE_NODE_NAME",
|
||||
"OPENFLARE_NODE_IP",
|
||||
"OPENFLARE_DATA_DIR",
|
||||
"OPENFLARE_OPENRESTY_PATH",
|
||||
"OPENFLARE_PAGES_DIR",
|
||||
"OPENFLARE_HEARTBEAT_INTERVAL",
|
||||
"OPENFLARE_REQUEST_TIMEOUT",
|
||||
"OPENFLARE_OPENRESTY_OBSERVABILITY_PORT",
|
||||
"OPENFLARE_MMDB_PATH",
|
||||
"OPENFLARE_MMDB_UPDATE_INTERVAL",
|
||||
"OPENFLARE_MMDB_DOWNLOAD_URL",
|
||||
"OPENFLARE_CITY_MMDB_PATH",
|
||||
"OPENFLARE_CITY_MMDB_DOWNLOAD_URL",
|
||||
} {
|
||||
if strings.TrimSpace(os.Getenv(key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
overrideString := func(key string, target *string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AccessToken)
|
||||
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
|
||||
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
|
||||
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
||||
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
|
||||
overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath)
|
||||
overrideString("OPENFLARE_PAGES_DIR", &cfg.PagesDir)
|
||||
overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath)
|
||||
overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL)
|
||||
overrideString("OPENFLARE_CITY_MMDB_PATH", &cfg.CityMMDBPath)
|
||||
overrideString("OPENFLARE_CITY_MMDB_DOWNLOAD_URL", &cfg.CityMMDBDownloadURL)
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" {
|
||||
if duration, err := parseDurationValue(value); err == nil {
|
||||
cfg.HeartbeatInterval = duration
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_REQUEST_TIMEOUT")); value != "" {
|
||||
if duration, err := parseDurationValue(value); err == nil {
|
||||
cfg.RequestTimeout = duration
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_MMDB_UPDATE_INTERVAL")); value != "" {
|
||||
if duration, err := parseDurationValue(value); err == nil {
|
||||
cfg.MMDBUpdateInterval = duration
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT")); value != "" {
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(value, "%d", &port); err == nil {
|
||||
cfg.OpenrestyObservabilityPort = port
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseDurationValue(value string) (MillisecondDuration, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if parsed, err := time.ParseDuration(trimmed); err == nil {
|
||||
return MillisecondDuration(parsed), nil
|
||||
}
|
||||
ms, err := strconv.ParseInt(trimmed, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return MillisecondDuration(time.Duration(ms) * time.Millisecond), nil
|
||||
}
|
||||
|
||||
func usesSlashPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/")
|
||||
}
|
||||
|
||||
func joinManagedPath(base string, relative string) string {
|
||||
if usesSlashPath(base) {
|
||||
return pathpkg.Join(filepath.ToSlash(base), relative)
|
||||
}
|
||||
return filepath.Join(base, relative)
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if cfg.ServerURL == "" {
|
||||
return errors.New("server_url 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
|
||||
return errors.New("agent_token 和 discovery_token 不能同时为空")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
return errors.New("node_name 不能为空")
|
||||
}
|
||||
if cfg.NodeIP == "" {
|
||||
return errors.New("node_ip 不能为空")
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort <= 0 || cfg.OpenrestyObservabilityPort > 65535 {
|
||||
return errors.New("openresty_observability_port 必须在 1-65535 之间")
|
||||
}
|
||||
if cfg.ObservabilityReplayMinutes <= 0 {
|
||||
return errors.New("observability_replay_minutes 必须大于 0")
|
||||
}
|
||||
if cfg.MMDBUpdateInterval <= 0 {
|
||||
return errors.New("mmdb_update_interval 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitialAuthToken returns the agent access token, falling back to the discovery token if absent.
|
||||
func (cfg *Config) InitialAuthToken() string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
if token := strings.TrimSpace(cfg.AccessToken); token != "" {
|
||||
return token
|
||||
}
|
||||
return strings.TrimSpace(cfg.DiscoveryToken)
|
||||
}
|
||||
|
||||
func (cfg *Config) toConfigFile() configFile {
|
||||
var file configFile
|
||||
if err := transferPersistedConfig(&file, cfg); err != nil {
|
||||
return configFile{}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// Save persists the current configuration back to its original file path.
|
||||
func (cfg *Config) Save() error {
|
||||
if cfg == nil {
|
||||
return errors.New("config 不能为空")
|
||||
}
|
||||
if cfg.configPath == "" {
|
||||
return errors.New("config path 未初始化")
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg.toConfigFile(), "", " ") //nolint:gosec // agent token must be persisted in local config file
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.configPath, data, configFilePerm)
|
||||
}
|
||||
|
||||
func detectHostname() string {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/share/edge/nodeip"
|
||||
"Wavelet/openflare/share/geoip"
|
||||
"Wavelet/openflare/share/geoip/iputil"
|
||||
)
|
||||
|
||||
func TestLoadDefaultsToManagedBinaryPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.DataDir != filepath.Join(dir, "data") {
|
||||
t.Fatalf("unexpected data dir: %s", cfg.DataDir)
|
||||
}
|
||||
if cfg.OpenrestyPath != "openresty" {
|
||||
t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath)
|
||||
}
|
||||
if cfg.MainConfigPath != filepath.Join(dir, "data", defaultMainConfigRelativePath) {
|
||||
t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath)
|
||||
}
|
||||
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultRouteConfigRelativePath) {
|
||||
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
|
||||
}
|
||||
if cfg.AccessLogPath != filepath.Join(dir, "data", defaultAccessLogRelativePath) {
|
||||
t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath)
|
||||
}
|
||||
if cfg.CertDir != filepath.Join(dir, "data", defaultCertDirRelativePath) {
|
||||
t.Fatalf("unexpected cert dir: %s", cfg.CertDir)
|
||||
}
|
||||
if cfg.LuaDir != filepath.Join(dir, "data", defaultLuaDirRelativePath) {
|
||||
t.Fatalf("unexpected lua dir: %s", cfg.LuaDir)
|
||||
}
|
||||
if cfg.RuntimeConfigDir != filepath.Join(dir, "data", defaultRuntimeConfigDirRelativePath) {
|
||||
t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir)
|
||||
}
|
||||
if cfg.CityMMDBPath != filepath.Join(dir, "data", defaultCityMMDBRelativePath) {
|
||||
t.Fatalf("unexpected city mmdb path: %s", cfg.CityMMDBPath)
|
||||
}
|
||||
if cfg.CityMMDBDownloadURL != defaultCityMMDBDownloadURL {
|
||||
t.Fatalf("unexpected city mmdb download URL: %s", cfg.CityMMDBDownloadURL)
|
||||
}
|
||||
if cfg.OpenrestyCertDir != cfg.CertDir {
|
||||
t.Fatalf("unexpected openresty cert dir: %s", cfg.OpenrestyCertDir)
|
||||
}
|
||||
if cfg.OpenrestyLuaDir != cfg.LuaDir {
|
||||
t.Fatalf("unexpected openresty lua dir: %s", cfg.OpenrestyLuaDir)
|
||||
}
|
||||
if cfg.StatePath != filepath.Join(dir, "data", defaultStateRelativePath) {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
if cfg.ObservabilityBufferPath != filepath.Join(dir, "data", defaultObservabilityBufferRelativePath) {
|
||||
t.Fatalf("unexpected observability buffer path: %s", cfg.ObservabilityBufferPath)
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort != defaultOpenRestyObservabilityPort {
|
||||
t.Fatalf("unexpected openresty observability port: %d", cfg.OpenrestyObservabilityPort)
|
||||
}
|
||||
if cfg.ObservabilityReplayMinutes != defaultObservabilityReplayMinutes {
|
||||
t.Fatalf("unexpected observability replay minutes: %d", cfg.ObservabilityReplayMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
|
||||
"main_config_path": "/tmp/nginx.conf",
|
||||
"route_config_path": "/tmp/routes.conf",
|
||||
"state_path": "/tmp/agent-state.json",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.MainConfigPath != "/tmp/nginx.conf" {
|
||||
t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath)
|
||||
}
|
||||
if cfg.RouteConfigPath != "/tmp/routes.conf" {
|
||||
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
|
||||
}
|
||||
if cfg.StatePath != "/tmp/agent-state.json" {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
if cfg.ObservabilityBufferPath != filepath.Join(dir, "data", defaultObservabilityBufferRelativePath) {
|
||||
t.Fatalf("unexpected observability buffer path: %s", cfg.ObservabilityBufferPath)
|
||||
}
|
||||
if cfg.OpenrestyCertDir != cfg.CertDir {
|
||||
t.Fatalf("expected path mode openresty cert dir to equal cert dir, got %s / %s", cfg.OpenrestyCertDir, cfg.CertDir)
|
||||
}
|
||||
if cfg.OpenrestyLuaDir != cfg.LuaDir {
|
||||
t.Fatalf("expected path mode openresty lua dir to equal lua dir, got %s / %s", cfg.OpenrestyLuaDir, cfg.LuaDir)
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort != defaultOpenRestyObservabilityPort {
|
||||
t.Fatalf("unexpected path mode openresty observability port: %d", cfg.OpenrestyObservabilityPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNormalizesExplicitResolvers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
"openresty_resolvers": []string{" 10.0.0.2 ", "10.0.0.2", "", "1.1.1.1"},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
expected := []string{"10.0.0.2", "1.1.1.1"}
|
||||
if len(cfg.OpenrestyResolvers) != len(expected) {
|
||||
t.Fatalf("unexpected resolver count: %#v", cfg.OpenrestyResolvers)
|
||||
}
|
||||
for index, value := range expected {
|
||||
if cfg.OpenrestyResolvers[index] != value {
|
||||
t.Fatalf("unexpected resolver at %d: got %q want %q", index, cfg.OpenrestyResolvers[index], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
"data_dir": "/srv/openflare",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.RouteConfigPath != "/srv/openflare/"+defaultRouteConfigRelativePath {
|
||||
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
|
||||
}
|
||||
if cfg.MainConfigPath != "/srv/openflare/"+defaultMainConfigRelativePath {
|
||||
t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath)
|
||||
}
|
||||
if cfg.AccessLogPath != "/srv/openflare/"+defaultAccessLogRelativePath {
|
||||
t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath)
|
||||
}
|
||||
if cfg.StatePath != "/srv/openflare/"+defaultStateRelativePath {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
if cfg.ObservabilityBufferPath != "/srv/openflare/"+defaultObservabilityBufferRelativePath {
|
||||
t.Fatalf("unexpected observability buffer path: %s", cfg.ObservabilityBufferPath)
|
||||
}
|
||||
if cfg.CertDir != "/srv/openflare/"+defaultCertDirRelativePath {
|
||||
t.Fatalf("unexpected cert dir: %s", cfg.CertDir)
|
||||
}
|
||||
if cfg.LuaDir != "/srv/openflare/"+defaultLuaDirRelativePath {
|
||||
t.Fatalf("unexpected lua dir: %s", cfg.LuaDir)
|
||||
}
|
||||
if cfg.RuntimeConfigDir != "/srv/openflare/"+defaultRuntimeConfigDirRelativePath {
|
||||
t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("OPENFLARE_SERVER_URL", "http://127.0.0.1:3000")
|
||||
t.Setenv("OPENFLARE_AGENT_TOKEN", "token")
|
||||
t.Setenv("OPENFLARE_NODE_NAME", "edge-env")
|
||||
t.Setenv("OPENFLARE_NODE_IP", "10.0.0.9")
|
||||
t.Setenv("OPENFLARE_DATA_DIR", "/srv/openflare-env")
|
||||
t.Setenv("OPENFLARE_OPENRESTY_PATH", "/usr/bin/openresty")
|
||||
t.Setenv("OPENFLARE_HEARTBEAT_INTERVAL", "45s")
|
||||
t.Setenv("OPENFLARE_REQUEST_TIMEOUT", "2500")
|
||||
t.Setenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT", "19091")
|
||||
|
||||
cfg, err := Load(filepath.Join(dir, "missing-agent.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AccessToken != "token" {
|
||||
t.Fatalf("unexpected env auth config: %#v", cfg)
|
||||
}
|
||||
if cfg.OpenrestyPath != "/usr/bin/openresty" {
|
||||
t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath)
|
||||
}
|
||||
if cfg.DataDir != "/srv/openflare-env" {
|
||||
t.Fatalf("unexpected data dir: %s", cfg.DataDir)
|
||||
}
|
||||
if cfg.HeartbeatInterval.Duration() != 45*time.Second {
|
||||
t.Fatalf("unexpected heartbeat interval: %s", cfg.HeartbeatInterval)
|
||||
}
|
||||
if cfg.RequestTimeout.Duration() != 2500*time.Millisecond {
|
||||
t.Fatalf("unexpected request timeout: %s", cfg.RequestTimeout)
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort != 19091 {
|
||||
t.Fatalf("unexpected observability port: %d", cfg.OpenrestyObservabilityPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDetectsOutboundIPWhenNodeIPMissing(t *testing.T) {
|
||||
nodeip.ResetCacheForTest()
|
||||
previousLookup := nodeip.LookupOutboundIP
|
||||
nodeip.LookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
|
||||
return net.ParseIP("8.8.8.8"), nil
|
||||
}
|
||||
defer func() {
|
||||
nodeip.LookupOutboundIP = previousLookup
|
||||
nodeip.ResetCacheForTest()
|
||||
}()
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.NodeIP != "8.8.8.8" {
|
||||
t.Fatalf("expected outbound IP, got %s", cfg.NodeIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFallsBackToLocalIPWhenOutboundLookupFails(t *testing.T) {
|
||||
nodeip.ResetCacheForTest()
|
||||
previousOutboundLookup := nodeip.LookupOutboundIP
|
||||
previousLocalLookup := nodeip.LookupLocalIP
|
||||
nodeip.LookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
|
||||
return nil, errors.New("realip.cc unavailable")
|
||||
}
|
||||
nodeip.LookupLocalIP = func() string {
|
||||
return "9.9.9.9"
|
||||
}
|
||||
defer func() {
|
||||
nodeip.LookupOutboundIP = previousOutboundLookup
|
||||
nodeip.LookupLocalIP = previousLocalLookup
|
||||
nodeip.ResetCacheForTest()
|
||||
}()
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.NodeIP != "9.9.9.9" {
|
||||
t.Fatalf("expected local fallback IP, got %s", cfg.NodeIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvOverridesConfigFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://old:3000","agent_token":"old","node_name":"edge-01","node_ip":"10.0.0.8","openresty_path":"/old/openresty"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
t.Setenv("OPENFLARE_SERVER_URL", "http://new:3000")
|
||||
t.Setenv("OPENFLARE_AGENT_TOKEN", "new-token")
|
||||
t.Setenv("OPENFLARE_OPENRESTY_PATH", "/new/openresty")
|
||||
t.Setenv("OPENFLARE_CITY_MMDB_PATH", "/new/GeoLite2-City.mmdb")
|
||||
t.Setenv("OPENFLARE_CITY_MMDB_DOWNLOAD_URL", "https://geo.example/GeoLite2-City.mmdb")
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "http://new:3000" {
|
||||
t.Fatalf("expected server url from env, got %s", cfg.ServerURL)
|
||||
}
|
||||
if cfg.AccessToken != "new-token" {
|
||||
t.Fatalf("expected token from env, got %s", cfg.AccessToken)
|
||||
}
|
||||
if cfg.OpenrestyPath != "/new/openresty" {
|
||||
t.Fatalf("expected openresty path from env, got %s", cfg.OpenrestyPath)
|
||||
}
|
||||
if cfg.CityMMDBPath != "/new/GeoLite2-City.mmdb" || cfg.CityMMDBDownloadURL != "https://geo.example/GeoLite2-City.mmdb" {
|
||||
t.Fatalf("unexpected City MMDB env overrides: %s / %s", cfg.CityMMDBPath, cfg.CityMMDBDownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeepsExplicitCityMMDBConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := `{"server_url":"http://127.0.0.1:3000","agent_token":"token","node_name":"edge-01","node_ip":"10.0.0.8","city_mmdb_path":"/custom/GeoLite2-City.mmdb","city_mmdb_download_url":"https://custom.example/GeoLite2-City.mmdb"}`
|
||||
if err := os.WriteFile(configPath, []byte(payload), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.CityMMDBPath != "/custom/GeoLite2-City.mmdb" || cfg.CityMMDBDownloadURL != "https://custom.example/GeoLite2-City.mmdb" {
|
||||
t.Fatalf("explicit City MMDB config changed: %s / %s", cfg.CityMMDBPath, cfg.CityMMDBDownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesMillisecondsForIntervals(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
"heartbeat_interval": 30000,
|
||||
"request_timeout": 1500,
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.HeartbeatInterval.Duration() != 30*time.Second {
|
||||
t.Fatalf("unexpected heartbeat interval: %s", cfg.HeartbeatInterval)
|
||||
}
|
||||
if cfg.RequestTimeout.Duration() != 1500*time.Millisecond {
|
||||
t.Fatalf("unexpected request timeout: %s", cfg.RequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://127.0.0.1:3000","agent_token":"token","node_name":"edge-01","node_ip":"10.0.0.8"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
cfg.ExtVersion = "1.27.1.2"
|
||||
cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second)
|
||||
cfg.RequestTimeout = MillisecondDuration(7 * time.Second)
|
||||
cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"}
|
||||
|
||||
if err = cfg.Save(); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read saved config: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err = json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to decode saved config: %v", err)
|
||||
}
|
||||
if _, ok := decoded["agent_version"]; ok {
|
||||
t.Fatal("agent_version should not be persisted")
|
||||
}
|
||||
if _, ok := decoded["nginx_version"]; ok {
|
||||
t.Fatal("nginx_version should not be persisted")
|
||||
}
|
||||
if decoded["heartbeat_interval"] != float64(5000) {
|
||||
t.Fatalf("unexpected heartbeat interval: %#v", decoded["heartbeat_interval"])
|
||||
}
|
||||
if decoded["request_timeout"] != float64(7000) {
|
||||
t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"])
|
||||
}
|
||||
resolvers, ok := decoded["openresty_resolvers"].([]any)
|
||||
if !ok || len(resolvers) != 2 || resolvers[0] != "10.0.0.2" || resolvers[1] != "1.1.1.1" {
|
||||
t.Fatalf("unexpected resolvers: %#v", decoded["openresty_resolvers"])
|
||||
}
|
||||
if decoded["openresty_observability_port"] != float64(defaultOpenRestyObservabilityPort) {
|
||||
t.Fatalf("unexpected observability port: %#v", decoded["openresty_observability_port"])
|
||||
}
|
||||
if decoded["observability_replay_minutes"] != float64(defaultObservabilityReplayMinutes) {
|
||||
t.Fatalf("unexpected observability replay minutes: %#v", decoded["observability_replay_minutes"])
|
||||
}
|
||||
if decoded["city_mmdb_path"] != cfg.CityMMDBPath || decoded["city_mmdb_download_url"] != cfg.CityMMDBDownloadURL {
|
||||
t.Fatalf("City MMDB config was not persisted: %#v", decoded)
|
||||
}
|
||||
if _, ok := decoded["nginx_path"]; ok {
|
||||
t.Fatal("legacy nginx_path should not be persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialAuthToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
agentToken string
|
||||
discoveryToken string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "prefer agent token",
|
||||
agentToken: "agent-token",
|
||||
discoveryToken: "discovery-token",
|
||||
expected: "agent-token",
|
||||
},
|
||||
{
|
||||
name: "fallback to discovery token",
|
||||
agentToken: " ",
|
||||
discoveryToken: "discovery-token",
|
||||
expected: "discovery-token",
|
||||
},
|
||||
{
|
||||
name: "nil config returns empty string",
|
||||
agentToken: "",
|
||||
discoveryToken: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var cfg *Config
|
||||
if tt.name != "nil config returns empty string" {
|
||||
cfg = &Config{
|
||||
AccessToken: tt.agentToken,
|
||||
DiscoveryToken: tt.discoveryToken,
|
||||
}
|
||||
}
|
||||
if token := cfg.InitialAuthToken(); token != tt.expected {
|
||||
t.Fatalf("unexpected initial auth token: %q", token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeIPPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "public ipv4 preferred",
|
||||
ip: "8.8.8.8",
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "private ipv4 fallback",
|
||||
ip: "10.0.0.8",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "link local ignored",
|
||||
ip: "169.254.1.10",
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "loopback ignored",
|
||||
ip: "127.0.0.1",
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "nil ignored",
|
||||
ip: "",
|
||||
expected: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var parsed net.IP
|
||||
if tt.ip != "" {
|
||||
parsed = net.ParseIP(tt.ip)
|
||||
}
|
||||
if got := iputil.Score(parsed); got != tt.expected {
|
||||
t.Fatalf("unexpected priority for %q: got %d want %d", tt.ip, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import edgeconfig "Wavelet/openflare/share/edge/config"
|
||||
|
||||
// MillisecondDuration is an alias for the edge config millisecond-precision duration type.
|
||||
type MillisecondDuration = edgeconfig.MillisecondDuration
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
// Version is the current agent version string, overridden at build time.
|
||||
var Version = "dev"
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package geoipdata holds shared GeoIP database filename constants.
|
||||
//
|
||||
// MaxMind MMDB files are NOT embedded into the agent binary. Docker images
|
||||
// COPY them onto the default data paths; bare binary installs seed via download
|
||||
// on first start (see geoipupdate).
|
||||
package geoipdata
|
||||
|
||||
const (
|
||||
// DefaultMMDBName is the default Country database filename.
|
||||
DefaultMMDBName = "GeoLite2-Country.mmdb"
|
||||
// DefaultCityMMDBName is the default City database filename.
|
||||
DefaultCityMMDBName = "GeoLite2-City.mmdb"
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package geoipupdate schedules local MaxMind GeoIP database updates for the agent.
|
||||
package geoipupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/share/geoip"
|
||||
)
|
||||
|
||||
// Updater periodically downloads a fresh GeoIP MMDB file and seeds missing
|
||||
// databases via download (or relies on image-provided files under data_dir).
|
||||
type Updater struct {
|
||||
MMDBPath string
|
||||
DownloadURL string
|
||||
CityMMDBPath string
|
||||
CityDownloadURL string
|
||||
UpdateInterval time.Duration
|
||||
downloadDatabase func(context.Context, string, string) error
|
||||
}
|
||||
|
||||
// EnsureInitialDatabases downloads any missing Country/City MMDB once.
|
||||
// When files already exist (e.g. Docker image COPY), this is a no-op.
|
||||
// Network is used only when a managed path is absent — not for binary embeds.
|
||||
func (u *Updater) EnsureInitialDatabases(ctx context.Context) error {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return u.ensureMissingDatabases(ctx)
|
||||
}
|
||||
|
||||
func (u *Updater) ensureMissingDatabases(ctx context.Context) error {
|
||||
databases := u.managedDatabases()
|
||||
var errs []error
|
||||
for _, database := range databases {
|
||||
if database.path == "" || database.downloadURL == "" {
|
||||
continue
|
||||
}
|
||||
exists, err := fileExists(database.path)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("stat GeoIP %s mmdb failed: %w", database.name, err))
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if err := u.download(ctx, database.path, database.downloadURL); err != nil {
|
||||
errs = append(errs, fmt.Errorf("seed GeoIP %s mmdb failed: %w", database.name, err))
|
||||
continue
|
||||
}
|
||||
slog.Info("seeded GeoIP mmdb via download", "database", database.name, "path", database.path)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func fileExists(path string) (bool, error) {
|
||||
path = filepath.Clean(path)
|
||||
if path == "" || path == "." {
|
||||
return false, nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err == nil {
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, fmt.Errorf("GeoIP MMDB path is not a regular file: %s", path)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (u *Updater) download(ctx context.Context, path string, downloadURL string) error {
|
||||
if u.downloadDatabase != nil {
|
||||
return u.downloadDatabase(ctx, path, downloadURL)
|
||||
}
|
||||
return geoip.DownloadMaxMindDatabase(ctx, path, downloadURL)
|
||||
}
|
||||
|
||||
func (u *Updater) managedDatabases() []struct {
|
||||
name string
|
||||
path string
|
||||
downloadURL string
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
path string
|
||||
downloadURL string
|
||||
}{
|
||||
{name: "Country", path: u.MMDBPath, downloadURL: u.DownloadURL},
|
||||
{name: "City", path: u.CityMMDBPath, downloadURL: u.CityDownloadURL},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) updateDatabases(ctx context.Context) error {
|
||||
var errs []error
|
||||
for _, database := range u.managedDatabases() {
|
||||
if database.path == "" || database.downloadURL == "" {
|
||||
continue
|
||||
}
|
||||
if err := u.download(ctx, database.path, database.downloadURL); err != nil {
|
||||
errs = append(errs, fmt.Errorf("update GeoIP %s mmdb failed: %w", database.name, err))
|
||||
continue
|
||||
}
|
||||
slog.Info("GeoIP mmdb updated", "database", database.name, "path", database.path)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Run starts the periodic GeoIP update loop and blocks until ctx is cancelled.
|
||||
func (u *Updater) Run(ctx context.Context) {
|
||||
if u == nil || u.MMDBPath == "" || u.UpdateInterval <= 0 {
|
||||
return
|
||||
}
|
||||
if err := u.EnsureInitialDatabases(ctx); err != nil {
|
||||
slog.Warn("initialize GeoIP databases failed", "country_path", u.MMDBPath, "city_path", u.CityMMDBPath, "error", err)
|
||||
}
|
||||
ticker := time.NewTicker(u.UpdateInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := u.updateDatabases(ctx); err != nil {
|
||||
slog.Warn("update GeoIP databases failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoipupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureInitialDatabasesDownloadsMissingOnly(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
countryPath := filepath.Join(tempDir, "GeoLite2-Country.mmdb")
|
||||
cityPath := filepath.Join(tempDir, "GeoLite2-City.mmdb")
|
||||
if err := os.WriteFile(cityPath, []byte("existing-city"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var downloaded []string
|
||||
updater := &Updater{
|
||||
MMDBPath: countryPath,
|
||||
DownloadURL: "https://geo.example/GeoLite2-Country.mmdb",
|
||||
CityMMDBPath: cityPath,
|
||||
CityDownloadURL: "https://geo.example/GeoLite2-City.mmdb",
|
||||
downloadDatabase: func(_ context.Context, path, _ string) error {
|
||||
downloaded = append(downloaded, path)
|
||||
return os.WriteFile(path, []byte("downloaded"), 0o600)
|
||||
},
|
||||
}
|
||||
|
||||
if err := updater.EnsureInitialDatabases(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureInitialDatabases failed: %v", err)
|
||||
}
|
||||
if !slices.Equal(downloaded, []string{countryPath}) {
|
||||
t.Fatalf("expected only missing Country download, got %#v", downloaded)
|
||||
}
|
||||
if data, err := os.ReadFile(cityPath); err != nil || string(data) != "existing-city" {
|
||||
t.Fatalf("existing City must stay untouched, data=%q err=%v", data, err)
|
||||
}
|
||||
if data, err := os.ReadFile(countryPath); err != nil || string(data) != "downloaded" {
|
||||
t.Fatalf("Country should be seeded via download, data=%q err=%v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitialDatabasesNoOpWhenPresent(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
countryPath := filepath.Join(tempDir, "GeoLite2-Country.mmdb")
|
||||
cityPath := filepath.Join(tempDir, "GeoLite2-City.mmdb")
|
||||
if err := os.WriteFile(countryPath, []byte("c"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cityPath, []byte("city"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &Updater{
|
||||
MMDBPath: countryPath,
|
||||
DownloadURL: "https://geo.example/GeoLite2-Country.mmdb",
|
||||
CityMMDBPath: cityPath,
|
||||
CityDownloadURL: "https://geo.example/GeoLite2-City.mmdb",
|
||||
downloadDatabase: func(_ context.Context, path, downloadURL string) error {
|
||||
t.Fatalf("must not download when files exist: %s %s", path, downloadURL)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if err := updater.EnsureInitialDatabases(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureInitialDatabases failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDatabasesAttemptsCityAfterCountryFailure(t *testing.T) {
|
||||
var paths []string
|
||||
updater := &Updater{
|
||||
MMDBPath: "/data/GeoLite2-Country.mmdb",
|
||||
DownloadURL: "https://geo.example/GeoLite2-Country.mmdb",
|
||||
CityMMDBPath: "/data/GeoLite2-City.mmdb",
|
||||
CityDownloadURL: "https://geo.example/GeoLite2-City.mmdb",
|
||||
downloadDatabase: func(_ context.Context, path, _ string) error {
|
||||
paths = append(paths, path)
|
||||
if path == "/data/GeoLite2-Country.mmdb" {
|
||||
return errors.New("country unavailable")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := updater.updateDatabases(context.Background())
|
||||
if err == nil || !slices.Equal(paths, []string{"/data/GeoLite2-Country.mmdb", "/data/GeoLite2-City.mmdb"}) {
|
||||
t.Fatalf("expected independent Country then City attempts, paths=%#v err=%v", paths, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitialDatabasesRejectsDirectoryPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
// Point Country path at a directory so fileExists must not treat it as seeded.
|
||||
updater := &Updater{
|
||||
MMDBPath: tempDir,
|
||||
DownloadURL: "https://geo.example/GeoLite2-Country.mmdb",
|
||||
downloadDatabase: func(_ context.Context, path, downloadURL string) error {
|
||||
t.Fatalf("must not download when path is a directory: %s %s", path, downloadURL)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := updater.EnsureInitialDatabases(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when MMDB path is a directory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("expected regular-file error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package heartbeat implements the periodic heartbeat cycle executed by the agent,
|
||||
// including payload preparation, config sync, WAF IP group application, and observability buffering.
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/config"
|
||||
"Wavelet/openflare/plugins/agent/observability"
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
"Wavelet/openflare/plugins/agent/updater"
|
||||
edgeheartbeat "Wavelet/openflare/share/edge/heartbeat"
|
||||
"Wavelet/openflare/share/edge/nodeip"
|
||||
)
|
||||
|
||||
// SyncService is the interface used by Cycle to sync active configuration and WAF IP groups.
|
||||
type SyncService interface {
|
||||
SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
WAFIPGroupChecksums() (map[string]string, error)
|
||||
ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error
|
||||
}
|
||||
|
||||
// SettingsApplier is the interface used by Cycle to apply agent settings received from the server.
|
||||
type SettingsApplier interface {
|
||||
Apply(settings *protocol.AgentSettings) (intervalChanged bool)
|
||||
RestartOpenrestyIfNeeded(ctx context.Context)
|
||||
}
|
||||
|
||||
// Cycle holds the dependencies required to execute a single agent heartbeat cycle.
|
||||
type Cycle struct {
|
||||
Config *config.Config
|
||||
StateStore *state.Store
|
||||
ObservabilityBuffer *state.ObservabilityBufferStore
|
||||
Heartbeat API
|
||||
Sync SyncService
|
||||
Updater *updater.Service
|
||||
RecordSyncError func(err error)
|
||||
}
|
||||
|
||||
// Perform executes one complete heartbeat cycle: sends the heartbeat, syncs config, and applies settings.
|
||||
func (c *Cycle) Perform(ctx context.Context, nodeID string, startup bool, settings SettingsApplier) (bool, error) {
|
||||
payload, ackWindows := c.PrepareHeartbeatPayload(ctx, nodeID)
|
||||
heartbeatResult, err := c.Heartbeat.Heartbeat(ctx, payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
c.AckObservabilityWindows(ackWindows)
|
||||
if heartbeatResult == nil {
|
||||
heartbeatResult = &protocol.HeartbeatResult{}
|
||||
}
|
||||
mode := "periodic"
|
||||
if startup {
|
||||
mode = "startup"
|
||||
}
|
||||
slog.Debug("agent heartbeat succeeded", "mode", mode, "node_id", nodeID)
|
||||
|
||||
var changed bool
|
||||
if settings != nil {
|
||||
changed = settings.Apply(heartbeatResult.AgentSettings)
|
||||
}
|
||||
c.ApplyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups)
|
||||
if startup {
|
||||
if err = c.Sync.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
c.recordSyncError(err)
|
||||
slog.Error("agent startup sync failed", "error", err)
|
||||
} else {
|
||||
slog.Debug("agent startup sync completed")
|
||||
}
|
||||
} else if err = c.Sync.SyncOnce(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
c.recordSyncError(err)
|
||||
slog.Error("agent sync failed", "error", err)
|
||||
}
|
||||
if settings != nil {
|
||||
settings.RestartOpenrestyIfNeeded(ctx)
|
||||
}
|
||||
edgeheartbeat.TryAutoUpdate(ctx, c.Updater, agentSettingsToAutoUpdate(heartbeatResult.AgentSettings), "agent")
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// NodePayload builds and returns the full NodePayload to be sent in a heartbeat request.
|
||||
func (c *Cycle) NodePayload(ctx context.Context, nodeID string) protocol.NodePayload {
|
||||
snapshot, _ := c.StateStore.Load()
|
||||
openrestyStatus := strings.TrimSpace(snapshot.OpenrestyStatus)
|
||||
if openrestyStatus == "" {
|
||||
openrestyStatus = protocol.OpenrestyStatusUnknown
|
||||
}
|
||||
profile := observability.BuildProfile(c.Config, c.StateStore)
|
||||
edgeSnapshot := observability.CollectEdgeHealth(ctx, c.Config)
|
||||
accessLogs := observability.CollectAccessLogs(c.Config, c.StateStore)
|
||||
metricSnapshot := observability.BuildSnapshot(c.Config, c.StateStore)
|
||||
edgeHealth := observability.BuildEdgeHealth(edgeSnapshot, openrestyStatus, snapshot.OpenrestyMessage)
|
||||
healthEvents := observability.BuildHealthEvents(snapshot)
|
||||
|
||||
ip := c.Config.NodeIP
|
||||
if !c.Config.NodeIPConfigured {
|
||||
ip = nodeip.DetectWithContext(ctx)
|
||||
}
|
||||
|
||||
payload := protocol.NodePayload{
|
||||
SchemaVersion: 2,
|
||||
NodeID: nodeID,
|
||||
Name: c.Config.NodeName,
|
||||
IP: ip,
|
||||
Version: c.Config.Version,
|
||||
ExtVersion: c.Config.ExtVersion,
|
||||
CurrentVersion: snapshot.CurrentVersion,
|
||||
LastError: snapshot.LastError,
|
||||
OpenrestyStatus: openrestyStatus,
|
||||
OpenrestyMessage: snapshot.OpenrestyMessage,
|
||||
Profile: profile,
|
||||
HostMetrics: metricSnapshot,
|
||||
EdgeHealth: edgeHealth,
|
||||
AccessLogs: accessLogs,
|
||||
HealthEvents: healthEvents,
|
||||
}
|
||||
if c.Sync != nil {
|
||||
checksums, err := c.Sync.WAFIPGroupChecksums()
|
||||
if err != nil {
|
||||
slog.Debug("load local waf ip group checksums failed", "error", err)
|
||||
} else if len(checksums) > 0 {
|
||||
payload.WAFIPGroupChecksums = checksums
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// PrepareHeartbeatPayload constructs the heartbeat payload with buffered observability records and returns the window timestamps to acknowledge.
|
||||
func (c *Cycle) PrepareHeartbeatPayload(ctx context.Context, nodeID string) (protocol.NodePayload, []int64) {
|
||||
payload := c.NodePayload(ctx, nodeID)
|
||||
if c.ObservabilityBuffer == nil || (payload.HostMetrics == nil && payload.EdgeHealth == nil && len(payload.AccessLogs) == 0) {
|
||||
return payload, nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
retainAfterUnix := now.Add(-time.Duration(c.Config.ObservabilityReplayMinutes) * time.Minute).Unix()
|
||||
windowStartedAtUnix := state.ObservabilityWindowStartedAt(payload.HostMetrics, payload.EdgeHealth)
|
||||
if windowStartedAtUnix <= 0 {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
record := state.ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: windowStartedAtUnix,
|
||||
HostMetrics: payload.HostMetrics,
|
||||
EdgeHealth: payload.EdgeHealth,
|
||||
AccessLogs: payload.AccessLogs,
|
||||
QueuedAtUnix: now.Unix(),
|
||||
}
|
||||
if err := c.ObservabilityBuffer.Upsert(record, retainAfterUnix); err != nil {
|
||||
slog.Error("upsert observability buffer failed", "error", err)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
records, err := c.ObservabilityBuffer.Replayable(windowStartedAtUnix, retainAfterUnix)
|
||||
if err != nil {
|
||||
slog.Error("load replayable observability buffer failed", "error", err)
|
||||
return payload, []int64{windowStartedAtUnix}
|
||||
}
|
||||
|
||||
ackWindows := make([]int64, 0, len(records)+1)
|
||||
buffered := make([]protocol.BufferedObservabilityRecord, 0, len(records))
|
||||
for _, item := range records {
|
||||
if item.WindowStartedAtUnix <= 0 {
|
||||
continue
|
||||
}
|
||||
buffered = append(buffered, protocol.BufferedObservabilityRecord{
|
||||
CapturedAtUnix: item.WindowStartedAtUnix,
|
||||
HostMetrics: item.HostMetrics,
|
||||
EdgeHealth: item.EdgeHealth,
|
||||
AccessLogs: item.AccessLogs,
|
||||
})
|
||||
ackWindows = append(ackWindows, item.WindowStartedAtUnix)
|
||||
}
|
||||
payload.Buffered = buffered
|
||||
ackWindows = append(ackWindows, windowStartedAtUnix)
|
||||
return payload, ackWindows
|
||||
}
|
||||
|
||||
// AckObservabilityWindows acknowledges the given observability window timestamps in the buffer store.
|
||||
func (c *Cycle) AckObservabilityWindows(windowStartedAtUnix []int64) {
|
||||
if c.ObservabilityBuffer == nil || len(windowStartedAtUnix) == 0 {
|
||||
return
|
||||
}
|
||||
retainAfterUnix := time.Now().UTC().Add(-time.Duration(c.Config.ObservabilityReplayMinutes) * time.Minute).Unix()
|
||||
if err := c.ObservabilityBuffer.Ack(windowStartedAtUnix, retainAfterUnix); err != nil {
|
||||
slog.Error("ack observability buffer failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyWAFIPGroups applies the WAF IP groups received from the server via the SyncService.
|
||||
func (c *Cycle) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) {
|
||||
if len(groups) == 0 || c.Sync == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Sync.ApplyWAFIPGroups(ctx, groups); err != nil {
|
||||
c.recordSyncError(err)
|
||||
slog.Error("agent apply waf ip groups failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cycle) recordSyncError(err error) {
|
||||
if c.RecordSyncError != nil {
|
||||
c.RecordSyncError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// AgentSettingsToAutoUpdate converts AgentSettings to an AutoUpdateSettings value used by the edge heartbeat updater.
|
||||
func AgentSettingsToAutoUpdate(settings *protocol.AgentSettings) *edgeheartbeat.AutoUpdateSettings {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
return &edgeheartbeat.AutoUpdateSettings{
|
||||
AutoUpdate: settings.AutoUpdate,
|
||||
UpdateNow: settings.UpdateNow,
|
||||
UpdateRepo: settings.UpdateRepo,
|
||||
UpdateChannel: settings.UpdateChannel,
|
||||
UpdateTag: settings.UpdateTag,
|
||||
}
|
||||
}
|
||||
|
||||
func agentSettingsToAutoUpdate(settings *protocol.AgentSettings) *edgeheartbeat.AutoUpdateSettings {
|
||||
return AgentSettingsToAutoUpdate(settings)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
)
|
||||
|
||||
// RemoteClient is the interface that abstracts the remote API calls performed by Service.
|
||||
type RemoteClient interface {
|
||||
RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error)
|
||||
Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error)
|
||||
SetToken(token string)
|
||||
}
|
||||
|
||||
// API abstracts registration and heartbeat operations used by Cycle.
|
||||
type API interface {
|
||||
Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error)
|
||||
Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error)
|
||||
SetToken(token string)
|
||||
}
|
||||
|
||||
// Service wraps a RemoteClient to expose agent registration and heartbeat operations.
|
||||
type Service struct {
|
||||
client RemoteClient
|
||||
}
|
||||
|
||||
// New creates a new Service backed by the given RemoteClient.
|
||||
func New(client RemoteClient) *Service {
|
||||
return &Service{client: client}
|
||||
}
|
||||
|
||||
// Register sends a node registration request to the server.
|
||||
func (s *Service) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
|
||||
return s.client.RegisterNode(ctx, payload)
|
||||
}
|
||||
|
||||
// Heartbeat sends a heartbeat to the server using the service client.
|
||||
func (s *Service) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) {
|
||||
return s.client.Heartbeat(ctx, payload)
|
||||
}
|
||||
|
||||
// SetToken sets the authentication token for the service client.
|
||||
func (s *Service) SetToken(token string) {
|
||||
s.client.SetToken(token)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package httpclient provides an authenticated HTTP client for the agent.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
edgehttp "Wavelet/openflare/share/edge/httpclient"
|
||||
)
|
||||
|
||||
const pagesControlResponseMaxBytes = int64(64 * 1024)
|
||||
|
||||
// Client is a HTTP client used by the agent to communicate with the control plane server.
|
||||
type Client struct {
|
||||
base *edgehttp.Client
|
||||
}
|
||||
|
||||
// New creates a new Client instance with the specified base URL, token, and timeout.
|
||||
func New(baseURL string, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
base: edgehttp.New(baseURL, token, timeout, "X-Agent-Token"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterNode registers the agent node with the control plane server.
|
||||
func (c *Client) RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
|
||||
resp := protocol.APIResponse[protocol.RegisterNodeResponse]{}
|
||||
if err := c.base.PostJSON(ctx, "/api/v1/agent/nodes/register", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// Heartbeat sends a heartbeat payload to the control plane and returns the response result.
|
||||
func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) {
|
||||
resp := protocol.APIResponse[protocol.HeartbeatData]{}
|
||||
if err := c.base.PostJSON(ctx, "/api/v1/agent/nodes/heartbeat", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &protocol.HeartbeatResult{
|
||||
AgentSettings: resp.Data.AgentSettings,
|
||||
ActiveConfig: resp.Data.ActiveConfig,
|
||||
WAFIPGroups: resp.Data.WAFIPGroups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetActiveConfig retrieves the current active configuration from the control plane server.
|
||||
func (c *Client) GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error) {
|
||||
resp := protocol.APIResponse[protocol.ActiveConfigResponse]{}
|
||||
if err := c.base.GetJSON(ctx, "/api/v1/agent/config-versions/active", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// ReportApplyLog reports the configuration application logs back to the control plane.
|
||||
func (c *Client) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error {
|
||||
resp := protocol.APIResponse[json.RawMessage]{}
|
||||
if err := c.base.PostJSON(ctx, "/api/v1/agent/apply-logs", payload, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return edgehttp.APIError(resp.ErrorMsg)
|
||||
}
|
||||
|
||||
// SyncWAFIPGroups synchronizes WAF IP groups with the control plane server.
|
||||
func (c *Client) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error) {
|
||||
resp := protocol.APIResponse[protocol.WAFIPGroupSyncResponse]{}
|
||||
if err := c.base.PostJSON(ctx, "/api/v1/agent/waf/ip-groups/sync", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// GetPagesDeploymentHash returns the upload SHA-256 hash for the given Pages deployment ID.
|
||||
func (c *Client) GetPagesDeploymentHash(ctx context.Context, deploymentID uint) (string, error) {
|
||||
resp := protocol.APIResponse[protocol.PagesDeploymentHashResponse]{}
|
||||
if err := c.base.GetJSON(ctx, fmt.Sprintf("/api/v1/agent/pages/deployments/%d/hash", deploymentID), &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Data.Hash, nil
|
||||
}
|
||||
|
||||
// DownloadPagesDeploymentPackage streams the deployment package into dst while
|
||||
// enforcing maxBytes against both advertised and actual response sizes.
|
||||
func (c *Client) DownloadPagesDeploymentPackage(
|
||||
ctx context.Context,
|
||||
deploymentID uint,
|
||||
dst io.Writer,
|
||||
maxBytes int64,
|
||||
) (int64, error) {
|
||||
return c.downloadPagesPackage(
|
||||
ctx,
|
||||
fmt.Sprintf("/api/v1/agent/pages/deployments/%d/package", deploymentID),
|
||||
dst,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// GetPagesProjectLatestHash returns the active deployment package hash for a Pages project.
|
||||
func (c *Client) GetPagesProjectLatestHash(ctx context.Context, projectID uint) (*protocol.PagesProjectLatestHashResponse, error) {
|
||||
res, err := c.base.DoRaw(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/hash", projectID),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, err := readPagesControlResponse(res, pagesControlResponseMaxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, edgehttp.ReadBodyError(body, res.Status)
|
||||
}
|
||||
resp := protocol.APIResponse[protocol.PagesProjectLatestHashResponse]{}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// DownloadPagesProjectLatestPackage streams the active deployment package into
|
||||
// dst while enforcing maxBytes against both advertised and actual sizes.
|
||||
func (c *Client) DownloadPagesProjectLatestPackage(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
dst io.Writer,
|
||||
maxBytes int64,
|
||||
) (int64, error) {
|
||||
return c.downloadPagesPackage(
|
||||
ctx,
|
||||
fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/package", projectID),
|
||||
dst,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) downloadPagesPackage(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
dst io.Writer,
|
||||
maxBytes int64,
|
||||
) (int64, error) {
|
||||
if dst == nil {
|
||||
return 0, errors.New("pages package destination is required")
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
return 0, errors.New("pages package byte limit must be positive")
|
||||
}
|
||||
res, err := c.base.DoRaw(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, readErr := readPagesControlResponse(res, pagesControlResponseMaxBytes)
|
||||
if readErr != nil {
|
||||
return 0, readErr
|
||||
}
|
||||
return 0, edgehttp.ReadBodyError(body, res.Status)
|
||||
}
|
||||
return copyPagesPackageResponse(dst, res, maxBytes)
|
||||
}
|
||||
|
||||
func readPagesControlResponse(res *http.Response, maxBytes int64) ([]byte, error) {
|
||||
if res.ContentLength > maxBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"pages control response Content-Length %d exceeds limit %d",
|
||||
res.ContentLength,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1}
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pages control response: %w", err)
|
||||
}
|
||||
if int64(len(body)) > maxBytes {
|
||||
return nil, fmt.Errorf("pages control response body exceeds limit %d", maxBytes)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func copyPagesPackageResponse(dst io.Writer, res *http.Response, maxBytes int64) (int64, error) {
|
||||
if res.ContentLength > maxBytes {
|
||||
return 0, fmt.Errorf(
|
||||
"pages package Content-Length %d exceeds limit %d",
|
||||
res.ContentLength,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1}
|
||||
written, err := io.Copy(dst, limited)
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("stream pages package: %w", err)
|
||||
}
|
||||
if written > maxBytes {
|
||||
return written, fmt.Errorf("pages package body exceeds limit %d", maxBytes)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// SetToken updates the authentication token used for API requests.
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.base.SetToken(token)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloadPagesProjectLatestPackageRejectsChunkedBodyOverLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = io.WriteString(w, "123456")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(server.URL, "test-token", time.Second)
|
||||
var dst bytes.Buffer
|
||||
written, err := client.DownloadPagesProjectLatestPackage(
|
||||
context.Background(),
|
||||
7,
|
||||
&dst,
|
||||
4,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "body exceeds limit") {
|
||||
t.Fatalf("DownloadPagesProjectLatestPackage(chunked, limit=4) error = %v, want body limit error", err)
|
||||
}
|
||||
if written != 5 {
|
||||
t.Errorf("DownloadPagesProjectLatestPackage(chunked, limit=4) written = %d, want 5", written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyPagesPackageResponseRejectsAdvertisedContentLengthBeforeWrite(t *testing.T) {
|
||||
response := &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader("123456")),
|
||||
ContentLength: 6,
|
||||
}
|
||||
var dst bytes.Buffer
|
||||
written, err := copyPagesPackageResponse(&dst, response, 4)
|
||||
if err == nil || !strings.Contains(err.Error(), "Content-Length") {
|
||||
t.Fatalf("copyPagesPackageResponse(Content-Length=6, limit=4) error = %v, want Content-Length limit error", err)
|
||||
}
|
||||
if written != 0 || dst.Len() != 0 {
|
||||
t.Errorf("copyPagesPackageResponse(Content-Length=6, limit=4) wrote (%d, %d buffered), want no writes", written, dst.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyPagesPackageResponseRejectsForgedSmallContentLength(t *testing.T) {
|
||||
response := &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader("123456")),
|
||||
ContentLength: 2,
|
||||
}
|
||||
var dst bytes.Buffer
|
||||
written, err := copyPagesPackageResponse(&dst, response, 4)
|
||||
if err == nil || !strings.Contains(err.Error(), "body exceeds limit") {
|
||||
t.Fatalf("copyPagesPackageResponse(forged Content-Length=2, limit=4) error = %v, want body limit error", err)
|
||||
}
|
||||
if written != 5 {
|
||||
t.Errorf("copyPagesPackageResponse(forged Content-Length=2, limit=4) written = %d, want 5", written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPagesProjectLatestPackageBoundsChunkedErrorResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1)))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(server.URL, "test-token", time.Second)
|
||||
var dst bytes.Buffer
|
||||
_, err := client.DownloadPagesProjectLatestPackage(context.Background(), 7, &dst, 1024)
|
||||
if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") {
|
||||
t.Fatalf("DownloadPagesProjectLatestPackage(large chunked 400) error = %v, want bounded response error", err)
|
||||
}
|
||||
if dst.Len() != 0 {
|
||||
t.Errorf("DownloadPagesProjectLatestPackage(large chunked 400) wrote %d package bytes, want 0", dst.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPagesProjectLatestHashBoundsChunkedMetadataResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1)))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(server.URL, "test-token", time.Second)
|
||||
_, err := client.GetPagesProjectLatestHash(context.Background(), 7)
|
||||
if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") {
|
||||
t.Fatalf("GetPagesProjectLatestHash(large chunked metadata) error = %v, want bounded response error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package logging configures structured logging for the agent process.
|
||||
package logging
|
||||
|
||||
import edgelogging "Wavelet/openflare/share/edge/logging"
|
||||
|
||||
// Setup initialises structured logging for the agent process.
|
||||
func Setup() {
|
||||
edgelogging.Setup(edgelogging.Options{AddSource: true})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
// DefaultMimeTypes is the embedded nginx mime.types map used by generated configs.
|
||||
const DefaultMimeTypes = `
|
||||
types {
|
||||
text/html html htm shtml;
|
||||
text/css css;
|
||||
text/xml xml;
|
||||
image/gif gif;
|
||||
image/jpeg jpeg jpg;
|
||||
application/javascript js;
|
||||
application/atom+xml atom;
|
||||
application/rss+xml rss;
|
||||
|
||||
text/mathml mml;
|
||||
text/plain txt;
|
||||
text/vnd.sun.j2me.app-descriptor jad;
|
||||
text/vnd.wap.wml wml;
|
||||
text/x-component htc;
|
||||
|
||||
image/png png;
|
||||
image/svg+xml svg svgz;
|
||||
image/tiff tif tiff;
|
||||
image/vnd.wap.wbmp wbmp;
|
||||
image/webp webp;
|
||||
image/x-icon ico;
|
||||
image/x-jng jng;
|
||||
image/x-ms-bmp bmp;
|
||||
|
||||
application/font-woff woff;
|
||||
application/java-archive jar war ear;
|
||||
application/json json;
|
||||
application/mac-binhex40 hqx;
|
||||
application/msword doc;
|
||||
application/pdf pdf;
|
||||
application/postscript ps eps ai;
|
||||
application/rtf rtf;
|
||||
application/vnd.apple.mpegurl m3u8;
|
||||
application/vnd.google-earth.kml+xml kml;
|
||||
application/vnd.google-earth.kmz kmz;
|
||||
application/vnd.ms-excel xls;
|
||||
application/vnd.ms-fontobject eot;
|
||||
application/vnd.ms-powerpoint ppt;
|
||||
application/vnd.oasis.opendocument.graphics odg;
|
||||
application/vnd.oasis.opendocument.presentation odp;
|
||||
application/vnd.oasis.opendocument.spreadsheet ods;
|
||||
application/vnd.oasis.opendocument.text odt;
|
||||
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||
pptx;
|
||||
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
xlsx;
|
||||
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
docx;
|
||||
application/vnd.wap.wmlc wmlc;
|
||||
application/x-7z-compressed 7z;
|
||||
application/x-cocoa cco;
|
||||
application/x-java-archive-diff jardiff;
|
||||
application/x-java-jnlp-file jnlp;
|
||||
application/x-makeself run;
|
||||
application/x-perl pl pm;
|
||||
application/x-pilot prc pdb;
|
||||
application/x-rar-compressed rar;
|
||||
application/x-redhat-package-manager rpm;
|
||||
application/x-sea sea;
|
||||
application/x-shockwave-flash swf;
|
||||
application/x-stuffit sit;
|
||||
application/x-tcl tcl tk;
|
||||
application/x-x509-ca-cert der pem crt;
|
||||
application/x-xpinstall xpi;
|
||||
application/xhtml+xml xhtml;
|
||||
application/xspf+xml xspf;
|
||||
application/zip zip;
|
||||
|
||||
application/octet-stream bin exe dll;
|
||||
application/octet-stream deb;
|
||||
application/octet-stream dmg;
|
||||
application/octet-stream iso img;
|
||||
application/octet-stream msi msp msm;
|
||||
|
||||
audio/midi mid midi kar;
|
||||
audio/mpeg mp3;
|
||||
audio/ogg ogg;
|
||||
audio/x-m4a m4a;
|
||||
audio/x-realaudio ra;
|
||||
|
||||
video/3gpp 3gpp 3gp;
|
||||
video/mp2t ts;
|
||||
video/mp4 mp4;
|
||||
video/mpeg mpeg mpg;
|
||||
video/quicktime mov;
|
||||
video/webm webm;
|
||||
video/x-flv flv;
|
||||
video/x-m4v m4v;
|
||||
video/x-mng mng;
|
||||
video/x-ms-asf asx asf;
|
||||
video/x-ms-wmv wmv;
|
||||
video/x-msvideo avi;
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import "Wavelet/openflare/plugins/agent/protocol"
|
||||
|
||||
// Local OpenResty observability endpoint (target model):
|
||||
// GET /openflare/observability returns instantaneous health/connections only.
|
||||
// Business traffic is collected exclusively from access.log.
|
||||
|
||||
const openRestyObservabilityInitLua = `return
|
||||
`
|
||||
|
||||
// log.lua no longer accumulates business counters (access.log is the authority).
|
||||
const openRestyObservabilityLogLua = `return
|
||||
`
|
||||
|
||||
// read.lua exposes stub_status-style connection gauges as JSON.
|
||||
const openRestyObservabilityReadLua = `local cjson = require "cjson.safe"
|
||||
|
||||
local function read_stub_status()
|
||||
local res = ngx.location.capture("/openflare/stub_status")
|
||||
if not res or res.status ~= 200 or not res.body then
|
||||
return nil
|
||||
end
|
||||
local body = res.body
|
||||
local active = tonumber(string.match(body, "Active connections:%s*(%d+)")) or 0
|
||||
local reading = tonumber(string.match(body, "Reading:%s*(%d+)")) or 0
|
||||
local writing = tonumber(string.match(body, "Writing:%s*(%d+)")) or 0
|
||||
local waiting = tonumber(string.match(body, "Waiting:%s*(%d+)")) or 0
|
||||
return {
|
||||
active = active,
|
||||
reading = reading,
|
||||
writing = writing,
|
||||
waiting = waiting
|
||||
}
|
||||
end
|
||||
|
||||
local connections = read_stub_status()
|
||||
local payload = {
|
||||
ok = connections ~= nil,
|
||||
captured_at_unix = ngx.time(),
|
||||
connections = connections or {
|
||||
active = 0,
|
||||
reading = 0,
|
||||
writing = 0,
|
||||
waiting = 0
|
||||
}
|
||||
}
|
||||
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.status = ngx.HTTP_OK
|
||||
ngx.say(cjson.encode(payload))
|
||||
`
|
||||
|
||||
// ManagedObservabilityLuaFiles returns embedded Lua assets for OpenResty observability.
|
||||
func ManagedObservabilityLuaFiles() []protocol.SupportFile {
|
||||
return []protocol.SupportFile{
|
||||
{Path: "init.lua", Content: openRestyObservabilityInitLua},
|
||||
{Path: "log.lua", Content: openRestyObservabilityLogLua},
|
||||
{Path: "read.lua", Content: openRestyObservabilityReadLua},
|
||||
{Path: "observability/init.lua", Content: openRestyObservabilityInitLua},
|
||||
{Path: "observability/log.lua", Content: openRestyObservabilityLogLua},
|
||||
{Path: "observability/read.lua", Content: openRestyObservabilityReadLua},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManagedObservabilityLuaIsHealthOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := ManagedObservabilityLuaFiles()
|
||||
var logLua, readLua string
|
||||
for _, file := range files {
|
||||
switch file.Path {
|
||||
case "log.lua":
|
||||
logLua = file.Content
|
||||
case "read.lua":
|
||||
readLua = file.Content
|
||||
}
|
||||
}
|
||||
if logLua == "" || readLua == "" {
|
||||
t.Fatal("expected log.lua and read.lua")
|
||||
}
|
||||
// Business counters must not be written in log phase.
|
||||
if strings.Contains(logLua, "openresty_rx_bytes") ||
|
||||
strings.Contains(logLua, "request_count") {
|
||||
t.Fatal("log.lua must not accumulate business counters")
|
||||
}
|
||||
if !strings.Contains(readLua, "connections") || !strings.Contains(readLua, "ok") {
|
||||
t.Fatal("read.lua must expose ok + connections health snapshot")
|
||||
}
|
||||
if strings.Contains(readLua, "top_domains") || strings.Contains(readLua, "request_count") {
|
||||
t.Fatal("read.lua must not expose business traffic aggregates")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
)
|
||||
|
||||
//go:embed pow_static
|
||||
var powStaticFS embed.FS
|
||||
|
||||
const openRestyPowRuntimeLua = `local _M = {}
|
||||
|
||||
local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
local policy = require "pow.policy"
|
||||
local pow_sessions = ngx.shared.openflare_pow_sessions
|
||||
local pow_config_dict = ngx.shared.openflare_pow_config
|
||||
local cjson = require "cjson.safe"
|
||||
|
||||
local function session_cookie(value, ttl)
|
||||
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
|
||||
if ngx.var.scheme == "https" then cookie = cookie .. "; Secure" end
|
||||
return cookie
|
||||
end
|
||||
|
||||
-- evaluate is called by a DAG pow node. true continues along its next edge;
|
||||
-- false means the challenge flow has taken ownership of the request.
|
||||
function _M.evaluate(config)
|
||||
config = config or {}
|
||||
ngx.ctx.openflare_pow_config = config
|
||||
|
||||
local host = ngx.var.host
|
||||
if not host or host == "" then return true end
|
||||
local session_ttl = config.session_ttl or 600
|
||||
local uri = ngx.var.uri or ""
|
||||
local ua = ngx.var.http_user_agent or ""
|
||||
local remote_ip = ngx.var.remote_addr or ""
|
||||
|
||||
if policy.match_any(remote_ip, ua, uri, config.whitelist or {}) then return true end
|
||||
local blacklist = config.blacklist or {}
|
||||
if policy.has_entries(blacklist) and not policy.match_any(remote_ip, ua, uri, blacklist) then return true end
|
||||
|
||||
local cookie_val = ngx.var["cookie___openflare_pow"]
|
||||
if cookie_val and cookie_val ~= "" then
|
||||
local session_key = host .. ":" .. cookie_val
|
||||
if pow_sessions:get(session_key) then
|
||||
pow_sessions:set(session_key, "1", session_ttl)
|
||||
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
local api_prefix = "/.within.website/x/cmd/anubis/api/"
|
||||
local static_prefix = "/.within.website/x/cmd/anubis/static/"
|
||||
if string.sub(uri, 1, #api_prefix) == api_prefix or string.sub(uri, 1, #static_prefix) == static_prefix then
|
||||
return false
|
||||
end
|
||||
|
||||
local config_key = "_request_config:" .. (ngx.var.request_id or ngx.md5(host .. uri .. tostring(ngx.now())))
|
||||
pow_config_dict:set(config_key, cjson.encode(config), config.challenge_ttl or 300)
|
||||
local challenge_args = {
|
||||
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
|
||||
host = host,
|
||||
openflare_pow_config_key = config_key,
|
||||
}
|
||||
ngx.req.set_uri_args(challenge_args)
|
||||
ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge", challenge_args)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Compatibility entrypoint for old rendered routes. PoW selection now belongs
|
||||
-- exclusively to WAF graph nodes, so this function intentionally does nothing.
|
||||
function _M.check()
|
||||
return true
|
||||
end
|
||||
|
||||
return _M
|
||||
`
|
||||
|
||||
/* Removed legacy request-time configuration scanner. Graph execution now calls
|
||||
evaluate(config) with the reached node.
|
||||
local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
local cjson = require "cjson.safe"
|
||||
local policy = require "pow.policy"
|
||||
|
||||
local pow_config_dict = ngx.shared.openflare_pow_config
|
||||
local pow_sessions = ngx.shared.openflare_pow_sessions
|
||||
|
||||
local function session_cookie(value, ttl)
|
||||
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
|
||||
if ngx.var.scheme == "https" then
|
||||
cookie = cookie .. "; Secure"
|
||||
end
|
||||
return cookie
|
||||
end
|
||||
|
||||
-- Lazy-load pow_config from file; reload when content changes
|
||||
local function load_pow_config()
|
||||
local config_paths = {
|
||||
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_config.json",
|
||||
"/etc/nginx/openflare-lua/waf_config.json",
|
||||
"/usr/local/openresty/nginx/conf/waf_config.json"
|
||||
}
|
||||
for _, config_path in ipairs(config_paths) do
|
||||
local f = io.open(config_path, "r")
|
||||
if f then
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
local current_hash = ngx.md5(content or "")
|
||||
|
||||
if current_hash == pow_config_dict:get("_config_hash") then
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear old domain/site entries
|
||||
local old_keys = pow_config_dict:get("_domain_keys")
|
||||
if old_keys then
|
||||
for domain in string.gmatch(old_keys, "[^\n]+") do
|
||||
pow_config_dict:delete(domain)
|
||||
end
|
||||
end
|
||||
|
||||
local domain_keys = {}
|
||||
if content and content ~= "" and content ~= "{}" then
|
||||
local ok, decoded = pcall(cjson.decode, content)
|
||||
if ok and decoded and decoded.rule_groups and decoded.site_rule_groups then
|
||||
-- Build rule groups map (group ID -> PoWConfig)
|
||||
local groups = {}
|
||||
for _, group in ipairs(decoded.rule_groups) do
|
||||
if group.pow_enabled then
|
||||
groups[tostring(group.id)] = group.pow_config or {}
|
||||
end
|
||||
end
|
||||
-- Build site name to pow_config map
|
||||
for site, group_ids in pairs(decoded.site_rule_groups) do
|
||||
local pow_config = nil
|
||||
-- Check custom group IDs first
|
||||
for _, id in ipairs(group_ids) do
|
||||
pow_config = groups[tostring(id)]
|
||||
if pow_config then
|
||||
break
|
||||
end
|
||||
end
|
||||
-- If not found, check global group IDs
|
||||
if not pow_config then
|
||||
for _, group in ipairs(decoded.rule_groups) do
|
||||
if group.is_global and group.pow_enabled then
|
||||
pow_config = group.pow_config or {}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if pow_config ~= nil then
|
||||
pow_config_dict:set(site, cjson.encode({enabled = true, config = pow_config}), 0)
|
||||
domain_keys[#domain_keys+1] = site
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0)
|
||||
pow_config_dict:set("_config_hash", current_hash, 0)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
if pow_config_dict:add("_pow_unreadable_config_logged", true, 60) then
|
||||
ngx.log(ngx.WARN, "openflare pow config is not readable by worker; check directory permissions under ", "__OPENFLARE_RUNTIME_CONFIG_DIR__")
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
load_pow_config()
|
||||
|
||||
local host = ngx.var.host
|
||||
if not host or host == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local site = ngx.var.openflare_waf_site or ""
|
||||
if site == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local config_raw = pow_config_dict:get(site)
|
||||
if not config_raw then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, route_config = pcall(cjson.decode, config_raw)
|
||||
if not ok or not route_config then
|
||||
return
|
||||
end
|
||||
|
||||
if not route_config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local config = route_config.config or {}
|
||||
local session_ttl = config.session_ttl or 600
|
||||
local uri = ngx.var.uri or ""
|
||||
local ua = ngx.var.http_user_agent or ""
|
||||
local remote_ip = ngx.var.remote_addr or ""
|
||||
|
||||
-- Check whitelist: if matched, skip PoW
|
||||
local whitelist = config.whitelist or {}
|
||||
if policy.match_any(remote_ip, ua, uri, whitelist) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check blacklist: if matched, require PoW
|
||||
local blacklist = config.blacklist or {}
|
||||
local has_blacklist = policy.has_entries(blacklist)
|
||||
local need_pow = false
|
||||
if has_blacklist then
|
||||
need_pow = policy.match_any(remote_ip, ua, uri, blacklist)
|
||||
else
|
||||
-- No blacklist means all non-whitelisted need PoW
|
||||
need_pow = true
|
||||
end
|
||||
|
||||
if not need_pow then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check valid session cookie
|
||||
local cookie_val = ngx.var["cookie___openflare_pow"]
|
||||
if cookie_val and cookie_val ~= "" then
|
||||
local session_key = host .. ":" .. cookie_val
|
||||
local session_data = pow_sessions:get(session_key)
|
||||
if session_data then
|
||||
pow_sessions:set(session_key, "1", session_ttl)
|
||||
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- If requesting the challenge API endpoints, let them through (handled by content_by_lua)
|
||||
local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/"
|
||||
local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/"
|
||||
if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then
|
||||
return
|
||||
end
|
||||
if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then
|
||||
return
|
||||
end
|
||||
|
||||
-- Render the challenge page through an internal redirect so the browser stays
|
||||
-- on the originally requested URL instead of seeing a 302 hop.
|
||||
ngx.req.set_uri_args({
|
||||
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
|
||||
host = host
|
||||
})
|
||||
return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")
|
||||
end
|
||||
|
||||
return _M
|
||||
*/
|
||||
|
||||
const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
return require("pow.runtime").check()
|
||||
`
|
||||
|
||||
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
|
||||
|
||||
local pow_challenges = ngx.shared.openflare_pow_challenges
|
||||
local pow_config_dict = ngx.shared.openflare_pow_config
|
||||
|
||||
local function generate_entropy()
|
||||
local pieces = {
|
||||
tostring(ngx.now()),
|
||||
tostring(ngx.worker.pid()),
|
||||
tostring(math.random()),
|
||||
ngx.var.remote_addr or "",
|
||||
ngx.var.http_user_agent or "",
|
||||
ngx.var.request_id or "",
|
||||
}
|
||||
return table.concat(pieces, ":")
|
||||
end
|
||||
|
||||
local args = ngx.req.get_uri_args()
|
||||
local host = args["host"] or ngx.var.host or ""
|
||||
local redir = args["redir"] or ""
|
||||
|
||||
local config = ngx.ctx.openflare_pow_config
|
||||
local config_key = args["openflare_pow_config_key"] or ""
|
||||
if type(config) ~= "table" and config_key ~= "" then
|
||||
local config_raw = pow_config_dict:get(config_key)
|
||||
if config_raw then
|
||||
config = cjson.decode(config_raw)
|
||||
end
|
||||
end
|
||||
if config_key ~= "" then pow_config_dict:delete(config_key) end
|
||||
if type(config) ~= "table" then
|
||||
ngx.status = 403
|
||||
ngx.say("PoW graph node was not evaluated for this request")
|
||||
return
|
||||
end
|
||||
local difficulty = config.difficulty or 4
|
||||
local algorithm = config.algorithm or "fast"
|
||||
local challenge_ttl = config.challenge_ttl or 300
|
||||
local session_ttl = config.session_ttl or 600
|
||||
|
||||
-- Generate challenge data without depending on ngx.random_bytes, which is not
|
||||
-- available in every OpenResty runtime build.
|
||||
local entropy = generate_entropy()
|
||||
local challenge_id = ngx.md5(entropy .. ":id")
|
||||
local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b")
|
||||
|
||||
-- Store challenge
|
||||
local challenge_info = cjson.encode({
|
||||
data = challenge_data,
|
||||
difficulty = difficulty,
|
||||
host = host,
|
||||
redir = redir,
|
||||
session_ttl = session_ttl
|
||||
})
|
||||
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
|
||||
|
||||
local static_prefix = "/.within.website/x/cmd/anubis/static/"
|
||||
local accept_lang = ngx.var.http_accept_language or ""
|
||||
local lang = "en"
|
||||
if string.find(accept_lang, "zh") then
|
||||
lang = "zh-CN"
|
||||
end
|
||||
|
||||
local t_title = "Making sure you're not a bot!"
|
||||
local t_status = "Loading..."
|
||||
local t_protected = "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown."
|
||||
local t_why = "Why am I seeing this?"
|
||||
local t_why_desc = "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically."
|
||||
local t_noscript = "JavaScript is required to pass this verification. Please enable JavaScript and reload."
|
||||
|
||||
if lang == "zh-CN" then
|
||||
t_title = "正在确认你是不是机器人!"
|
||||
t_status = "加载中..."
|
||||
t_protected = "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。"
|
||||
t_why = "为什么我会看到这个?"
|
||||
t_why_desc = "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。"
|
||||
t_noscript = "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。"
|
||||
end
|
||||
|
||||
ngx.header.content_type = "text/html; charset=utf-8"
|
||||
ngx.say([[<!DOCTYPE html>
|
||||
<html lang="]] .. lang .. [[">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>]] .. t_title .. [[</title>
|
||||
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
|
||||
<style>
|
||||
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
|
||||
.centered-div{text-align:center}
|
||||
#status{font-variant-numeric:tabular-nums}
|
||||
#progress{display:none;width:min(20rem,90%);height:2rem;border-radius:1rem;overflow:hidden;margin:1rem 0 2rem;outline-offset:2px;outline:#b16286 solid 4px}
|
||||
.bar-inner{background-color:#b16286;height:100%;width:0;transition:width .25s ease-in}
|
||||
</style>
|
||||
<script id="anubis_version" type="application/json">"openflare-pow"</script>
|
||||
<script id="anubis_challenge" type="application/json">]] .. cjson.encode({
|
||||
challenge = {
|
||||
id = challenge_id,
|
||||
randomData = challenge_data,
|
||||
method = algorithm
|
||||
},
|
||||
rules = {
|
||||
difficulty = difficulty,
|
||||
algorithm = algorithm
|
||||
}
|
||||
}) .. [[</script>
|
||||
<script id="anubis_base_prefix" type="application/json">""</script>
|
||||
<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<main>
|
||||
<h1 id="title" class="centered-div">]] .. t_title .. [[</h1>
|
||||
<div class="centered-div">
|
||||
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
|
||||
<p id="status">]] .. t_status .. [[</p>
|
||||
<p>]] .. t_protected .. [[</p>
|
||||
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
|
||||
<details>
|
||||
<summary>]] .. t_why .. [[</summary>
|
||||
<p>]] .. t_why_desc .. [[</p>
|
||||
</details>
|
||||
<noscript><p>]] .. t_noscript .. [[</p></noscript>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
|
||||
</body>
|
||||
</html>]])
|
||||
`
|
||||
|
||||
const openRestyPowVerifyLua = `local cjson = require "cjson.safe"
|
||||
|
||||
local pow_challenges = ngx.shared.openflare_pow_challenges
|
||||
local pow_sessions = ngx.shared.openflare_pow_sessions
|
||||
|
||||
local site = ngx.var.openflare_waf_site or ""
|
||||
if site == "" then
|
||||
ngx.status = 403
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "PoW site not resolved; openflare_waf_site is required"}))
|
||||
return
|
||||
end
|
||||
|
||||
local args = ngx.req.get_uri_args()
|
||||
local challenge_id = args["id"] or ""
|
||||
local response = args["response"] or ""
|
||||
local nonce_str = args["nonce"] or ""
|
||||
local redir = args["redir"] or ""
|
||||
local elapsed = args["elapsedTime"] or ""
|
||||
|
||||
if challenge_id == "" or response == "" or nonce_str == "" then
|
||||
ngx.status = 400
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "missing parameters"}))
|
||||
return
|
||||
end
|
||||
|
||||
local nonce = tonumber(nonce_str)
|
||||
if not nonce then
|
||||
ngx.status = 400
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "invalid nonce"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Get stored challenge
|
||||
local challenge_raw = pow_challenges:get(challenge_id)
|
||||
if not challenge_raw then
|
||||
ngx.status = 410
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "challenge expired or not found"}))
|
||||
return
|
||||
end
|
||||
|
||||
local ok, challenge_info = pcall(cjson.decode, challenge_raw)
|
||||
if not ok or not challenge_info then
|
||||
ngx.status = 500
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "invalid challenge data"}))
|
||||
return
|
||||
end
|
||||
|
||||
local challenge_data = challenge_info.data or ""
|
||||
local difficulty = challenge_info.difficulty or 4
|
||||
local host = challenge_info.host or ngx.var.host or ""
|
||||
local session_ttl = challenge_info.session_ttl or 600
|
||||
|
||||
-- Compute SHA-256(challenge_data + nonce)
|
||||
local calc_string = challenge_data .. tostring(math.floor(nonce))
|
||||
local calculated = ngx.sha1_bin ~= nil and "" or ""
|
||||
|
||||
-- Use resty.sha256 for proper SHA-256
|
||||
local sha256 = require "resty.sha256"
|
||||
local str = require "resty.string"
|
||||
local hasher = sha256:new()
|
||||
hasher:update(calc_string)
|
||||
local hash_bytes = hasher:final()
|
||||
local hash_hex = str.to_hex(hash_bytes)
|
||||
|
||||
-- Verify hash matches response
|
||||
if hash_hex ~= string.lower(response) then
|
||||
ngx.status = 403
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "hash mismatch"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Verify difficulty (leading zeros in hex)
|
||||
local prefix = string.rep("0", difficulty)
|
||||
if string.sub(hash_hex, 1, difficulty) ~= prefix then
|
||||
ngx.status = 403
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "insufficient difficulty"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Invalidate challenge (prevent replay)
|
||||
pow_challenges:delete(challenge_id)
|
||||
|
||||
-- Generate session token
|
||||
local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid())))
|
||||
|
||||
-- Store session
|
||||
pow_sessions:set(host .. ":" .. session_token, "1", session_ttl)
|
||||
|
||||
-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when
|
||||
-- the current request itself is HTTPS.
|
||||
local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl)
|
||||
if ngx.var.scheme == "https" then
|
||||
cookie = cookie .. "; Secure"
|
||||
end
|
||||
ngx.header["Set-Cookie"] = cookie
|
||||
|
||||
if redir ~= "" then
|
||||
return ngx.redirect(redir)
|
||||
end
|
||||
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({ok = true}))
|
||||
`
|
||||
|
||||
const openRestyPowPolicyLua = `local M = {}
|
||||
|
||||
local function match_ip(remote_ip, ips)
|
||||
if not ips or #ips == 0 then return false end
|
||||
for _, ip in ipairs(ips) do
|
||||
if ip == remote_ip then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_cidr(remote_ip, cidrs)
|
||||
if not cidrs or #cidrs == 0 then return false end
|
||||
for _, cidr in ipairs(cidrs) do
|
||||
local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$")
|
||||
if m then
|
||||
local mask_bits = tonumber(m[2])
|
||||
if mask_bits and mask_bits >= 0 and mask_bits <= 32 then
|
||||
local function ip_to_num(ip_str)
|
||||
local parts = {}
|
||||
for part in string.gmatch(ip_str, "%d+") do
|
||||
parts[#parts+1] = tonumber(part) or 0
|
||||
end
|
||||
if #parts ~= 4 then return 0 end
|
||||
return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4]
|
||||
end
|
||||
local remote_num = ip_to_num(remote_ip)
|
||||
local net_num = ip_to_num(m[1])
|
||||
if mask_bits == 0 then
|
||||
return true
|
||||
end
|
||||
local mask = math.floor(2^(32 - mask_bits))
|
||||
mask = 4294967296 - mask
|
||||
if bit.band(remote_num, mask) == bit.band(net_num, mask) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_path(uri, patterns)
|
||||
if not patterns or #patterns == 0 then return false end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c)
|
||||
if c == "*" then return ".*" end
|
||||
return "%" .. c
|
||||
end) .. "$", "i")
|
||||
if ok and match then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_path_regex(uri, patterns)
|
||||
if not patterns or #patterns == 0 then return false end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local ok, match = pcall(ngx.re.match, uri, pattern)
|
||||
if ok and match then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_ua(ua, patterns)
|
||||
if not patterns or #patterns == 0 then return false end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
if ua and string.find(ua, pattern, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function M.match_any(remote_ip, ua, uri, list)
|
||||
if not list then return false end
|
||||
if match_ip(remote_ip, list.ips) then return true end
|
||||
if match_cidr(remote_ip, list.ip_cidrs) then return true end
|
||||
if match_path(uri, list.paths) then return true end
|
||||
if match_path_regex(uri, list.path_regexes) then return true end
|
||||
if match_ua(ua, list.user_agents) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
function M.has_entries(list)
|
||||
if not list then return false end
|
||||
return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0
|
||||
end
|
||||
|
||||
return M
|
||||
`
|
||||
|
||||
// ManagedPowLuaFiles returns embedded Lua assets for proof-of-work challenges.
|
||||
func ManagedPowLuaFiles() []protocol.SupportFile {
|
||||
return []protocol.SupportFile{
|
||||
{Path: "pow/runtime.lua", Content: openRestyPowRuntimeLua},
|
||||
{Path: "pow/check.lua", Content: openRestyPowCheckLua},
|
||||
{Path: "pow/challenge.lua", Content: openRestyPowChallengeLua},
|
||||
{Path: "pow/verify.lua", Content: openRestyPowVerifyLua},
|
||||
{Path: "pow/policy.lua", Content: openRestyPowPolicyLua},
|
||||
}
|
||||
}
|
||||
|
||||
// ManagedPowStaticFiles returns embedded static assets served by the PoW module.
|
||||
func ManagedPowStaticFiles() ([]protocol.SupportFile, error) {
|
||||
var files []protocol.SupportFile
|
||||
entries, err := powStaticFS.ReadDir("pow_static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var walk func(dir string) error
|
||||
walk = func(dir string) error {
|
||||
entries, err := powStaticFS.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
fullPath := filepath.Join(dir, entry.Name())
|
||||
if entry.IsDir() {
|
||||
if err := walk(fullPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, err := powStaticFS.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Convert pow_static/css/xess.css -> pow/static/css/xess.css
|
||||
relPath := strings.TrimPrefix(fullPath, "pow_static/")
|
||||
files = append(files, protocol.SupportFile{
|
||||
Path: "pow/static/" + relPath,
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
fullPath := filepath.Join("pow_static", entry.Name())
|
||||
if entry.IsDir() {
|
||||
if err := walk(fullPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
data, err := powStaticFS.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relPath := strings.TrimPrefix(fullPath, "pow_static/")
|
||||
files = append(files, protocol.SupportFile{
|
||||
Path: "pow/static/" + relPath,
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestPowRuntimePassesConfigKeyToInternalChallenge(t *testing.T) {
|
||||
state := lua.NewState()
|
||||
defer state.Close()
|
||||
|
||||
if err := state.DoString(`
|
||||
package.preload["pow.policy"] = function()
|
||||
return {
|
||||
match_any = function() return false end,
|
||||
has_entries = function() return false end,
|
||||
}
|
||||
end
|
||||
package.preload["cjson.safe"] = function()
|
||||
return { encode = function() return "{}" end }
|
||||
end
|
||||
|
||||
local config_values = {}
|
||||
local config_dict = {}
|
||||
function config_dict:set(key, value) config_values[key] = value return true end
|
||||
function config_dict:get(key) return config_values[key] end
|
||||
|
||||
local sessions = {}
|
||||
function sessions:get() return nil end
|
||||
function sessions:set() return true end
|
||||
|
||||
ngx = {
|
||||
var = {
|
||||
host = "pow.example.com",
|
||||
uri = "/protected",
|
||||
scheme = "https",
|
||||
remote_addr = "192.0.2.1",
|
||||
http_user_agent = "test",
|
||||
request_id = "request-1",
|
||||
},
|
||||
ctx = {},
|
||||
header = {},
|
||||
shared = {
|
||||
openflare_pow_sessions = sessions,
|
||||
openflare_pow_config = config_dict,
|
||||
},
|
||||
req = {},
|
||||
now = function() return 1 end,
|
||||
}
|
||||
function ngx.req.set_uri_args(args) captured_uri_args = args end
|
||||
function ngx.exec(uri, args)
|
||||
captured_exec_uri = uri
|
||||
captured_exec_args = args
|
||||
end
|
||||
`); err != nil {
|
||||
t.Fatalf("prepare Lua runtime: %v", err)
|
||||
}
|
||||
|
||||
chunk, err := state.LoadString(openRestyPowRuntimeLua)
|
||||
if err != nil {
|
||||
t.Fatalf("load PoW runtime: %v", err)
|
||||
}
|
||||
if err := state.CallByParam(lua.P{Fn: chunk, NRet: 1, Protect: true}); err != nil {
|
||||
t.Fatalf("initialize PoW runtime: %v", err)
|
||||
}
|
||||
runtimeModule := state.Get(-1)
|
||||
state.Pop(1)
|
||||
|
||||
evaluate := state.GetField(runtimeModule, "evaluate")
|
||||
config := state.NewTable()
|
||||
config.RawSetString("challenge_ttl", lua.LNumber(300))
|
||||
if err := state.CallByParam(lua.P{Fn: evaluate, NRet: 1, Protect: true}, config); err != nil {
|
||||
t.Fatalf("evaluate PoW node: %v", err)
|
||||
}
|
||||
state.Pop(1)
|
||||
|
||||
if got := state.GetGlobal("captured_exec_uri").String(); got != "/.within.website/x/cmd/anubis/api/make-challenge" {
|
||||
t.Fatalf("unexpected internal challenge URI: %q", got)
|
||||
}
|
||||
execArgs, ok := state.GetGlobal("captured_exec_args").(*lua.LTable)
|
||||
if !ok {
|
||||
t.Fatal("expected ngx.exec to receive explicit challenge arguments")
|
||||
}
|
||||
if got := execArgs.RawGetString("openflare_pow_config_key").String(); got != "_request_config:request-1" {
|
||||
t.Fatalf("unexpected PoW config key: %q", got)
|
||||
}
|
||||
if state.GetGlobal("captured_uri_args") != execArgs {
|
||||
t.Fatal("expected URI arguments and internal redirect arguments to use the same table")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
@font-face {
|
||||
font-family: "Podkova";
|
||||
font-style: normal;
|
||||
font-weight: 400 800;
|
||||
font-display: swap;
|
||||
src: url("podkova.woff2") format("woff2");
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,149 @@
|
||||
:root {
|
||||
--body-sans-font: Geist, sans-serif;
|
||||
--body-preformatted-font: Iosevka Curly Iaso, monospace;
|
||||
--body-title-font: Podkova, serif;
|
||||
|
||||
--background: #1d2021;
|
||||
--text: #f9f5d7;
|
||||
--text-selection: #d3869b;
|
||||
--preformatted-background: #3c3836;
|
||||
--link-foreground: #b16286;
|
||||
--link-background: #282828;
|
||||
--blockquote-border-left: 1px solid #bdae93;
|
||||
|
||||
--progress-bar-outline: #b16286 solid 4px;
|
||||
--progress-bar-fill: #b16286;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--background: #f9f5d7;
|
||||
--text: #1d2021;
|
||||
--text-selection: #d3869b;
|
||||
--preformatted-background: #ebdbb2;
|
||||
--link-foreground: #b16286;
|
||||
--link-background: #fbf1c7;
|
||||
--blockquote-border-left: 1px solid #655c54;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./static/geist.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Podkova";
|
||||
font-style: normal;
|
||||
font-weight: 400 800;
|
||||
font-display: swap;
|
||||
src: url("./static/podkova.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Iosevka Curly";
|
||||
font-style: monospace;
|
||||
font-display: swap;
|
||||
src: url("./static/iosevka-curly.woff2") format("woff2");
|
||||
}
|
||||
|
||||
main {
|
||||
font-family: var(--body-sans-font);
|
||||
max-width: 50rem;
|
||||
padding: 2rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--text-selection);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.centered-div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#status {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.centered-div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#status {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#progress {
|
||||
display: none;
|
||||
width: min(20rem, 90%);
|
||||
height: 2rem;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0 2rem;
|
||||
outline-offset: 2px;
|
||||
outline: var(--progress-bar-outline);
|
||||
}
|
||||
|
||||
.bar-inner {
|
||||
background-color: var(--progress-bar-fill);
|
||||
height: 100%;
|
||||
width: 0;
|
||||
transition: width 0.25s ease-in;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.bar-inner {
|
||||
transition: width 0.25s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--preformatted-background);
|
||||
padding: 1em;
|
||||
border: 0;
|
||||
font-family: var(--body-preformatted-font);
|
||||
}
|
||||
|
||||
a,
|
||||
a:active,
|
||||
a:visited {
|
||||
color: var(--link-foreground);
|
||||
background-color: var(--link-background);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
margin-bottom: 0.1rem;
|
||||
font-family: var(--body-title-font);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: var(--blockquote-border-left);
|
||||
margin: 0.5em 10px;
|
||||
padding: 0.5em 10px;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
@licstart The following is the entire license notice for the
|
||||
JavaScript code in this page.
|
||||
|
||||
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
|
||||
used under the terms of the Apache 2 license.
|
||||
|
||||
@licend The above is the entire license notice
|
||||
for the JavaScript code in this page.
|
||||
*/
|
||||
(()=>{var k=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function n(c,b,w=5,e=null,g,u=Math.trunc(Math.max(k()/2,1))){console.debug("fast algo");let s="purejs";return window.isSecureContext&&(s="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),s="purejs"),new Promise((p,l)=>{let m=`${c.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${s}.mjs?cacheBuster=${c.version}`,f=[],d=!1,a=()=>{console.log("PoW aborted"),i(),l(new DOMException("Aborted","AbortError"))},i=()=>{d||(d=!0,f.forEach(r=>r.terminate()),e?.removeEventListener("abort",a))};if(e!=null){if(e.aborted)return a();e.addEventListener("abort",a,{once:!0})}for(let r=0;r<u;r++){let t=new Worker(m);t.onmessage=o=>{typeof o.data=="number"?g?.(o.data):(i(),p(o.data))},t.onerror=o=>{i(),l(o)},t.postMessage({data:b,difficulty:w,nonce:r,threads:u}),f.push(t)}})}var P={fast:n,slow:n};})();
|
||||
//# sourceMappingURL=index.mjs.map
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
@licstart The following is the entire license notice for the
|
||||
JavaScript code in this page.
|
||||
|
||||
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
|
||||
used under the terms of the Apache 2 license.
|
||||
|
||||
@licend The above is the entire license notice
|
||||
for the JavaScript code in this page.
|
||||
*/
|
||||
(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c<u;c++){let g=new Worker(M);g.onmessage=m=>{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null&&e&&window.location.href.startsWith(e)){let t=new URLSearchParams(window.location.search).get("redir");if(t){try{let u=new URL(t,window.location.href);if(u.protocol==="http:"||u.protocol==="https:")return t}catch(s){}}return window.location.href}return window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.textContent=l,n.textContent=f,s.src=w,i.style.display="none"};n.textContent=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.textContent=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.textContent=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})();
|
||||
//# sourceMappingURL=main.mjs.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
@licstart The following is the entire license notice for the
|
||||
JavaScript code in this page.
|
||||
|
||||
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
|
||||
used under the terms of the Apache 2 license.
|
||||
|
||||
@licend The above is the entire license notice
|
||||
for the JavaScript code in this page.
|
||||
*/
|
||||
(()=>{var h=new TextEncoder,y=async e=>{let s=h.encode(e);return await crypto.subtle.digest("SHA-256",s)},g=e=>e.reduce((s,a)=>s+a.toString(16).padStart(2,"0"),"");addEventListener("message",async({data:e})=>{let{data:s,difficulty:a,threads:d}=e,t=e.nonce,f=t===0,o=0,c=Math.floor(a/2),l=a%2!==0;for(;;){let u=await y(s+t),i=new Uint8Array(u),r=!0;for(let n=0;n<c;n++)if(i[n]!==0){r=!1;break}if(r&&l&&i[c]>>4!==0&&(r=!1),r){let n=g(i);postMessage({hash:n,data:s,difficulty:a,nonce:t});return}t+=d,o++,t%1!==0&&(t=Math.trunc(t)),f&&(o&1023)===0&&postMessage(t)}});})();
|
||||
//# sourceMappingURL=sha256-webcrypto.mjs.map
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"loading": "Loading...",
|
||||
"why_am_i_seeing": "Why am I seeing this?",
|
||||
"protected_by": "Protected by",
|
||||
"protected_from": "From",
|
||||
"made_with": "Made with ❤️ in 🇨🇦",
|
||||
"mascot_design": "Mascot design by",
|
||||
"ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.",
|
||||
"anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.",
|
||||
"hack_purpose": "Ultimately, this is a placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.",
|
||||
"simplified_explanation": "This is a measure against bots and malicious requests similar to a CAPTCHA. However, instead of having to do work yourself, your browser is given a calculation task that it has to solve to ensure that it is a valid client. This concept is called <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">Proof of Work</a>. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.",
|
||||
"jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.",
|
||||
"version_info": "This website is running Anubis version",
|
||||
"try_again": "Try again",
|
||||
"go_home": "Go home",
|
||||
"contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at",
|
||||
"connection_security": "Please wait a moment while we ensure the security of your connection.",
|
||||
"javascript_required": "Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works. A no-JS solution is a work-in-progress.",
|
||||
"benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.",
|
||||
"difficulty": "Difficulty:",
|
||||
"algorithm": "Algorithm:",
|
||||
"compare": "Compare:",
|
||||
"time": "Time",
|
||||
"iters": "Iters",
|
||||
"time_a": "Time A",
|
||||
"iters_a": "Iters A",
|
||||
"time_b": "Time B",
|
||||
"iters_b": "Iters B",
|
||||
"static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.",
|
||||
"authorization_required": "Authorization required",
|
||||
"cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain",
|
||||
"access_denied": "Access Denied: error code",
|
||||
"dronebl_entry": "DroneBL reported an entry",
|
||||
"see_dronebl_lookup": "see",
|
||||
"internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around",
|
||||
"invalid_redirect": "Invalid redirect",
|
||||
"redirect_not_parseable": "Redirect URL not parseable",
|
||||
"redirect_domain_not_allowed": "Redirect domain not allowed",
|
||||
"missing_required_forwarded_headers": "Missing required X-Forwarded-* headers",
|
||||
"failed_to_sign_jwt": "failed to sign JWT",
|
||||
"invalid_invocation": "Invalid invocation of MakeChallenge",
|
||||
"client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.",
|
||||
"oh_noes": "Oh noes!",
|
||||
"benchmarking_anubis": "Benchmarking Anubis!",
|
||||
"you_are_not_a_bot": "You are not a bot!",
|
||||
"making_sure_not_bot": "Making sure you're not a bot!",
|
||||
"celphase": "CELPHASE",
|
||||
"js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?",
|
||||
"js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?",
|
||||
"js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.",
|
||||
"js_context_not_secure": "Your context is not secure!",
|
||||
"js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
|
||||
"js_calculating": "Calculating...",
|
||||
"js_missing_feature": "Missing feature",
|
||||
"js_challenge_error": "Challenge error!",
|
||||
"js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.",
|
||||
"js_calculating_difficulty": "Calculating...<br/>Difficulty:",
|
||||
"js_speed": "Speed:",
|
||||
"js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.",
|
||||
"js_success": "Success!",
|
||||
"js_done_took": "Done! Took",
|
||||
"js_iterations": "iterations",
|
||||
"js_finished_reading": "I've finished reading, continue →",
|
||||
"js_calculation_error": "Calculation error!",
|
||||
"js_calculation_error_msg": "Failed to calculate challenge:"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user