merge(cordis): abort helpers and gold upgrade fixture

Bring Wavelet AbortNotFoundIfMissing/AbortBadRequestOnError, switch OpenFlare handlers to them, and pin the golden upgrade test to v3.5.4 via git archive.
This commit is contained in:
ryan
2026-08-30 18:03:04 +08:00
18 changed files with 177 additions and 79 deletions
+44 -5
View File
@@ -31,6 +31,7 @@ import (
const (
goldenRoot = "/Users/ryan/Code/Go/OpenFlare"
goldCommit = "9f79fb99"
goldGooseVersion = int64(202608090003)
sampleZoneDomain = "l3-upgrade-golden.example"
goldMigrateWait = 75 * time.Second
@@ -41,6 +42,7 @@ const (
var (
goldBinOnce sync.Once
goldBinPath string
goldSrcDir string
goldBinErr error
)
@@ -244,10 +246,46 @@ func killGolden(cmd *exec.Cmd) {
func buildGoldenBinary(t *testing.T) string {
t.Helper()
goldBinOnce.Do(func() {
if _, err := os.Stat(filepath.Join(goldenRoot, "main.go")); err != nil {
goldBinErr = fmt.Errorf("golden tree %s: %w", goldenRoot, err)
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
@@ -255,12 +293,12 @@ func buildGoldenBinary(t *testing.T) string {
}
out := filepath.Join(dir, "gold")
cmd := exec.Command("go", "build", "-o", out, ".")
cmd.Dir = goldenRoot
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: %w\n%s", err, buf.String())
goldBinErr = fmt.Errorf("go build golden %s: %w\n%s", goldCommit, err, buf.String())
return
}
goldBinPath = out
@@ -273,8 +311,9 @@ func buildGoldenBinary(t *testing.T) string {
func copyGoldConfig(t *testing.T, dir string) string {
t.Helper()
buildGoldenBinary(t)
dst := filepath.Join(dir, "config.yaml")
src, err := os.Open(filepath.Join(goldenRoot, "config.example.yaml")) //nolint:gosec // fixed golden path
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)
}
@@ -6,7 +6,6 @@ package dashboard
import (
"net/http"
"Wavelet/openflare/plugins/server/kernel/apiutil"
"Wavelet/pkg/response"
"github.com/gin-gonic/gin"
@@ -26,7 +25,7 @@ import (
// @Router /api/v1/d/dashboard/overview [get]
func GetOverviewHandler(c *gin.Context) {
overview, err := GetOverview(c.Request.Context())
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(overview))
@@ -44,7 +44,7 @@ func RegisterHandler(c *gin.Context) {
} else {
result, err = RegisterWithDiscovery(c.Request.Context(), payload)
}
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -76,7 +76,7 @@ func HeartbeatHandler(c *gin.Context) {
}
heartbeat, err := HeartbeatNode(c.Request.Context(), authNode, payload)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(heartbeat))
@@ -98,7 +98,7 @@ func GetActiveConfigHandler(c *gin.Context) {
return
}
config, err := GetActiveConfig(c.Request.Context())
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(config))
@@ -122,7 +122,7 @@ func SyncWAFIPGroupsHandler(c *gin.Context) {
return
}
result, err := SyncWAFIPGroups(c.Request.Context(), input)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -149,7 +149,7 @@ func ReportApplyLogHandler(c *gin.Context) {
payload.NodeID = authNode.NodeID
}
log, err := ReportApplyLog(c.Request.Context(), payload)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(log))
@@ -172,7 +172,7 @@ func GetPagesDeploymentHashHandler(c *gin.Context) {
return
}
hash, err := pages.GetDeploymentPackageHash(c.Request.Context(), deploymentID)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(protocol.PagesDeploymentHashResponse{
@@ -198,7 +198,7 @@ func DownloadPagesPackageHandler(c *gin.Context) {
return
}
packageObj, err := pages.OpenDeploymentPackage(c.Request.Context(), deploymentID)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
defer func() { _ = packageObj.Body.Close() }()
@@ -226,7 +226,7 @@ func GetPagesProjectLatestHashHandler(c *gin.Context) {
return
}
metadata, err := pages.GetProjectLatestPackageMetadata(c.Request.Context(), projectID)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(protocol.PagesProjectLatestHashResponse{
@@ -256,7 +256,7 @@ func DownloadPagesProjectLatestPackageHandler(c *gin.Context) {
return
}
packageObj, err := pages.OpenProjectLatestPackage(c.Request.Context(), projectID)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
defer func() { _ = packageObj.Body.Close() }()
@@ -45,7 +45,7 @@ func PostHeartbeat(c *gin.Context) {
}
result, err := Heartbeat(c.Request.Context(), node, payload)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -75,7 +75,7 @@ func GetActiveConfig(c *gin.Context) {
}
config, err := GetTunnelConfig(c.Request.Context(), node)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(config))
@@ -106,7 +106,7 @@ func PostApplyLog(c *gin.Context) {
}
log, err := ReportApplyLog(c.Request.Context(), payload)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(log))
@@ -19,7 +19,7 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, errNodeNotFound)
return response.AbortNotFoundIfMissing(c, err, errNodeNotFound)
}
// ListNodesHandler lists all nodes.
@@ -46,7 +46,7 @@ func PostHeartbeat(c *gin.Context) {
}
result, err := Heartbeat(c.Request.Context(), node, payload)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -39,7 +39,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
Hours: readQueryInt(c, "hours"),
BucketMinutes: readQueryInt(c, "bucket_minutes"),
})
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -70,11 +70,11 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
// @Router /api/v1/d/access-logs [get]
func GetAccessLogsHandler(c *gin.Context) {
query, err := readAccessLogQuery(c)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
logs, err := ListAccessLogs(c.Request.Context(), query)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(logs))
@@ -103,12 +103,12 @@ func GetAccessLogsHandler(c *gin.Context) {
// @Router /api/v1/d/access-logs/folds [get]
func GetFoldedAccessLogsHandler(c *gin.Context) {
query, err := readAccessLogQuery(c)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
query.FoldMinutes = readQueryInt(c, "fold_minutes")
logs, err := ListFoldedAccessLogs(c.Request.Context(), query)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(logs))
@@ -149,7 +149,7 @@ func GetFoldedAccessLogIPsHandler(c *gin.Context) {
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -190,7 +190,7 @@ func GetAccessLogIPSummariesHandler(c *gin.Context) {
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -221,7 +221,7 @@ func GetAccessLogIPTrendHandler(c *gin.Context) {
Hours: readQueryInt(c, "hours"),
BucketMinutes: readQueryInt(c, "bucket_minutes"),
})
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -250,7 +250,7 @@ func GetAccessLogIPAnalysisHandler(c *gin.Context) {
Host: c.Query("host"),
Hours: readQueryInt(c, "hours"),
})
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -276,7 +276,7 @@ func CleanupAccessLogsHandler(c *gin.Context) {
return
}
result, err := CleanupAccessLogs(c.Request.Context(), input)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -41,7 +41,7 @@ func GetStatusHandler(c *gin.Context) {
// @Router /api/v1/d/option [get]
func ListOptionsHandler(c *gin.Context) {
options, err := listOptions(c.Request.Context())
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(options))
@@ -66,7 +66,7 @@ func UpdateOptionHandler(c *gin.Context) {
if !apiutil.BindJSON(c, &option) {
return
}
if apiutil.AbortBadRequestOnError(c, updateOption(c.Request.Context(), option)) {
if response.AbortBadRequestOnError(c, updateOption(c.Request.Context(), option)) {
return
}
c.JSON(http.StatusOK, response.OKNil())
@@ -91,7 +91,7 @@ func UpdateOptionsBatchHandler(c *gin.Context) {
if !apiutil.BindJSON(c, &payload) {
return
}
if apiutil.AbortBadRequestOnError(c, updateOptionsBatch(c.Request.Context(), payload)) {
if response.AbortBadRequestOnError(c, updateOptionsBatch(c.Request.Context(), payload)) {
return
}
c.JSON(http.StatusOK, response.OKNil())
@@ -117,7 +117,7 @@ func LookupGeoIPHandler(c *gin.Context) {
return
}
view, err := lookupGeoIP(c.Request.Context(), request.Provider, request.IP)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
@@ -137,7 +137,7 @@ func LookupGeoIPHandler(c *gin.Context) {
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/uptimekuma/sync [post]
func SyncUptimeKumaHandler(c *gin.Context) {
if apiutil.AbortBadRequestOnError(c, syncUptimeKuma(c.Request.Context())) {
if response.AbortBadRequestOnError(c, syncUptimeKuma(c.Request.Context())) {
return
}
c.JSON(http.StatusOK, response.OK("同步成功"))
@@ -24,7 +24,7 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, errPagesProjectNotFound)
return response.AbortNotFoundIfMissing(c, err, errPagesProjectNotFound)
}
func handleSourceLogicError(c *gin.Context, err error) bool {
@@ -35,7 +35,7 @@ func GetApplyLogs(c *gin.Context) {
PageNo: readIntQuery(c, "pageNo", "page_no"),
PageSize: readIntQuery(c, "pageSize", "page_size"),
})
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -61,7 +61,7 @@ func CleanupApplyLogs(c *gin.Context) {
}
result, err := Cleanup(c.Request.Context(), input)
if apiutil.AbortBadRequestOnError(c, err) {
if response.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
@@ -16,7 +16,7 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
return response.AbortNotFoundIfMissing(c, err, "记录不存在")
}
func versionParam(c *gin.Context) (string, bool) {
@@ -83,7 +83,7 @@ func GetConfigVersionHandler(c *gin.Context) {
// @Router /api/v1/d/config-versions/active [get]
func GetActiveConfigVersionHandler(c *gin.Context) {
version, err := GetActiveConfigVersion(c.Request.Context())
if apiutil.AbortNotFoundIfMissing(c, err, errNoActiveVersion) {
if response.AbortNotFoundIfMissing(c, err, errNoActiveVersion) {
return
}
c.JSON(http.StatusOK, response.OK(version))
@@ -16,7 +16,7 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, errOriginNotFound)
return response.AbortNotFoundIfMissing(c, err, errOriginNotFound)
}
// GetOrigins 列出全部源站。
@@ -16,7 +16,7 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, errProxyRouteNotFound)
return response.AbortNotFoundIfMissing(c, err, errProxyRouteNotFound)
}
// GetProxyRoutes 列出全部代理规则。
@@ -16,7 +16,7 @@ func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
return response.AbortNotFoundIfMissing(c, err, "记录不存在")
}
// GetCertificates 列出 TLS 证书。
@@ -1,35 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package apiutil
import (
"errors"
"Wavelet/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AbortNotFoundIfMissing maps gorm.ErrRecordNotFound to 404; other errors to 400.
func AbortNotFoundIfMissing(c *gin.Context, err error, notFoundMsg string) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortNotFound(c, notFoundMsg)
return true
}
response.AbortBadRequest(c, err.Error())
return true
}
// AbortBadRequestOnError writes a 400 for any non-nil error.
func AbortBadRequestOnError(c *gin.Context, err error) bool {
if err == nil {
return false
}
response.AbortBadRequest(c, err.Error())
return true
}
+26
View File
@@ -4,9 +4,11 @@
package response
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AbortBadRequest 以 400 中断请求并将错误挂载到 Gin Error 链,供全局中间件统一记录 Trace 并响应。
@@ -43,3 +45,27 @@ func AbortTooManyRequests(c *gin.Context, msg string) {
func AbortConflict(c *gin.Context, msg string) {
AbortWithError(c, http.StatusConflict, msg)
}
// AbortNotFoundIfMissing 在 err 非空时中断请求:gorm.ErrRecordNotFound 映射为 404(文案 notFoundMsg),其余映射为 400(文案 err.Error())。
// 返回是否已中断。err 为 nil 时不写响应并返回 false。
func AbortNotFoundIfMissing(c *gin.Context, err error, notFoundMsg string) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
AbortNotFound(c, notFoundMsg)
return true
}
AbortBadRequest(c, err.Error())
return true
}
// AbortBadRequestOnError 在 err 非空时以 400 中断请求,文案为 err.Error()。
// 返回是否已中断。err 为 nil 时不写响应并返回 false。
func AbortBadRequestOnError(c *gin.Context, err error) bool {
if err == nil {
return false
}
AbortBadRequest(c, err.Error())
return true
}
@@ -0,0 +1,68 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package response
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestAbortNotFoundIfMissing(t *testing.T) {
t.Run("nil", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
assert.False(t, AbortNotFoundIfMissing(c, nil, "gone"))
assert.False(t, c.IsAborted())
assert.Empty(t, c.Errors)
})
t.Run("record not found", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
assert.True(t, AbortNotFoundIfMissing(c, gorm.ErrRecordNotFound, "记录不存在"))
assert.True(t, c.IsAborted())
var apiErr *APIError
require.True(t, errors.As(c.Errors.Last().Err, &apiErr))
assert.Equal(t, http.StatusNotFound, apiErr.Code)
assert.Equal(t, "记录不存在", apiErr.Msg)
})
t.Run("other error", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
assert.True(t, AbortNotFoundIfMissing(c, errors.New("boom"), "记录不存在"))
assert.True(t, c.IsAborted())
var apiErr *APIError
require.True(t, errors.As(c.Errors.Last().Err, &apiErr))
assert.Equal(t, http.StatusBadRequest, apiErr.Code)
assert.Equal(t, "boom", apiErr.Msg)
})
}
func TestAbortBadRequestOnError(t *testing.T) {
t.Run("nil", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
assert.False(t, AbortBadRequestOnError(c, nil))
assert.False(t, c.IsAborted())
})
t.Run("error", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
assert.True(t, AbortBadRequestOnError(c, errors.New("bad")))
assert.True(t, c.IsAborted())
var apiErr *APIError
require.True(t, errors.As(c.Errors.Last().Err, &apiErr))
assert.Equal(t, http.StatusBadRequest, apiErr.Code)
assert.Equal(t, "bad", apiErr.Msg)
})
}
+1
View File
@@ -13,6 +13,7 @@ sidebar: false
### 🛠 修复
- 人机验证与健康检查去掉双路径:浏览器只请求 `/api/v1/cap/challenge` 与 `/api/v1/cap/redeem`,探针只保留 `GET /api/healthz`(`{"status":"ok"}`)。旧的 `/api/cap/*`、`/api/health` 与 `/healthz` 不再注册。
- Handler 把「记录不存在 → 404、其它错误 → 400」的分支改走上游 `response.AbortNotFoundIfMissing` / `AbortBadRequestOnError`,不再在 OpenFlare 里各写一份。
- 控制面 `server` 插件按限界上下文重排目录:去掉 `openflare/` 与 `router/v1` 嵌套;业务在 `domain/`(site/fleet/pages 等),共享内核在 `kernel/`(model/repository 与适配器),HTTP 装配在 `httpapi`。接口路径与表结构不变。
- `server` 插件把 stamp、of_* SQL 与 ClickHouse 迁入单一 `migrate/` 包,updater 提到 `server/updater/`;删除已停用的 76 条历史迁移。全新安装会写入 OpenFlare 定时任务与产品配置默认值,已 stamp 的升级库不重插。