mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
7d71f1e4e1
- New internal/repository/logstore abstraction: exported domain interfaces (AccessLogStore/ObservabilityStore/UserAccessLogStore/StatusStore), config-driven provider (Active/Build/Migrating/SetConfigReader), GORM implementation for PostgreSQL/SQLite (incl. hourly rollups computed in real time, migration listers, PG partition maintenance), and a ClickHouse wrapper preserving the native batch path; repository facade delegates to logstore; import-lint test enforces apps never import analyticsrepo. - ClickHouse is now optional: the log DB is either the main DB (postgres when database.enabled, else sqlite) or clickhouse; boot validation + first-run seed; log_database / log_db_migration are protected keys. - New user task 切换日志数据库 (of_log_db_switch): freeze log writes, drain batch writers, copy all 6 raw log tables by id (preserving IDs) with target-partition pre-creation for PG, flip log_database on success, clear the freeze flag on failure. - Per-store retention (log_retention_days_*) with expiry cleanup folded into the daily system_cleanup task; legacy database_auto_cleanup_* and of_database_auto_cleanup decommissioned. - goose migrations: 6 log tables in PG (2 monthly-partitioned) + SQLite, retention config seeds, schedule cleanup; GET /api/v1/admin/status/log-database endpoint; frontend retention settings, switch-task UI and status badge; changelog and docs updated. docs(plan): log database decoupling implementation plan docs(design): log database decoupling design (ClickHouse optional)
381 lines
10 KiB
Go
381 lines
10 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package node
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare"
|
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func setReleaseHTTPClientForTest(client *http.Client) *http.Client {
|
|
previous := releaseHTTPClient
|
|
releaseHTTPClient = client
|
|
return previous
|
|
}
|
|
|
|
func setupNodeTestDB(t *testing.T) func() {
|
|
t.Helper()
|
|
|
|
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
|
DisableForeignKeyConstraintWhenMigrating: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, sqliteDB.AutoMigrate(
|
|
&model.OpenFlareNode{},
|
|
&model.SystemConfig{},
|
|
&model.OpenFlareApplyLog{},
|
|
))
|
|
|
|
db.SetDB(sqliteDB)
|
|
testhelper.SetupLogStoresForTest(t)
|
|
|
|
return func() {
|
|
db.SetDB(nil)
|
|
}
|
|
}
|
|
|
|
func TestCreateEdgeNode(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
view, err := CreateNode(ctx, Input{
|
|
Name: "edge-1",
|
|
IP: "10.0.0.1",
|
|
AutoUpdateEnabled: true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotZero(t, view.ID)
|
|
assert.True(t, strings.HasPrefix(view.NodeID, "node-"))
|
|
assert.Len(t, view.AccessToken, 32)
|
|
assert.Equal(t, "edge_node", view.NodeType)
|
|
assert.Equal(t, nodeStatusPending, view.Status)
|
|
assert.True(t, view.AutoUpdateEnabled)
|
|
}
|
|
|
|
func TestCreateTunnelRelayNode(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
view, err := CreateNode(ctx, Input{
|
|
Name: "relay-1",
|
|
NodeType: "tunnel_relay",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "tunnel_relay", view.NodeType)
|
|
assert.Equal(t, 7000, view.RelayBindPort)
|
|
assert.Equal(t, 8080, view.RelayVhostHTTPPort)
|
|
|
|
stored, err := repository.GetOpenFlareNodeByID(ctx, view.ID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, stored.RelayAuthToken)
|
|
}
|
|
|
|
func TestCreateTunnelClientNode(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
view, err := CreateNode(ctx, Input{
|
|
Name: "client-1",
|
|
NodeType: "tunnel_client",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "tunnel_client", view.NodeType)
|
|
}
|
|
|
|
func TestCreateNodeRequiresName(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
_, err := CreateNode(ctx, Input{IP: "10.0.0.2"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, errNodeNameRequired, err.Error())
|
|
}
|
|
|
|
func TestUpdateNode(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-update"})
|
|
require.NoError(t, err)
|
|
|
|
updated, err := UpdateNode(ctx, created.ID, Input{
|
|
Name: "edge-updated",
|
|
IP: "192.168.1.10",
|
|
AutoUpdateEnabled: true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "edge-updated", updated.Name)
|
|
assert.Equal(t, "192.168.1.10", updated.IP)
|
|
assert.True(t, updated.AutoUpdateEnabled)
|
|
}
|
|
|
|
func TestUpdateNodeDispatchesCloudflareSyncWhenIPChanges(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
created, err := CreateNode(ctx, Input{Name: "edge-update", IP: "192.0.2.10"})
|
|
require.NoError(t, err)
|
|
|
|
var dispatchedNodeID uint
|
|
restore := cf.SetDispatchTaskForTest(func(_ context.Context, taskType string, payload []byte, _ string) (string, error) {
|
|
assert.Equal(t, cf.TaskTypeSyncByNode, taskType)
|
|
assert.Contains(t, string(payload), `"node_id":`)
|
|
dispatchedNodeID = created.ID
|
|
return "task-1", nil
|
|
})
|
|
defer restore()
|
|
|
|
_, err = UpdateNode(ctx, created.ID, Input{Name: "edge-update", IP: "192.0.2.11"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, dispatchedNodeID)
|
|
}
|
|
|
|
func TestDeleteNode(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-delete"})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, DeleteNode(ctx, created.ID))
|
|
_, err = repository.GetOpenFlareNodeByID(ctx, created.ID)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
|
}
|
|
|
|
func TestListNodesWithApplyLogMetadata(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-list"})
|
|
require.NoError(t, err)
|
|
|
|
applyAt := time.Now().UTC().Truncate(time.Second)
|
|
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareApplyLog{
|
|
NodeID: created.NodeID,
|
|
Version: "20260618-001",
|
|
Result: "success",
|
|
Message: "ok",
|
|
Checksum: "checksum-1",
|
|
MainConfigChecksum: "main-1",
|
|
RouteConfigChecksum: "route-1",
|
|
SupportFileCount: 3,
|
|
CreatedAt: applyAt,
|
|
}).Error)
|
|
|
|
views, err := ListNodes(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, views, 1)
|
|
assert.Equal(t, "success", views[0].LatestApplyResult)
|
|
assert.Equal(t, "checksum-1", views[0].LatestApplyChecksum)
|
|
assert.Equal(t, 3, views[0].LatestSupportFileCount)
|
|
require.NotNil(t, views[0].LatestApplyAt)
|
|
assert.Equal(t, applyAt, views[0].LatestApplyAt.UTC())
|
|
}
|
|
|
|
func TestBootstrapTokenLifecycle(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
first, err := GetBootstrapToken(ctx)
|
|
require.NoError(t, err)
|
|
assert.Len(t, first.DiscoveryToken, 32)
|
|
|
|
second, err := GetBootstrapToken(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, first.DiscoveryToken, second.DiscoveryToken)
|
|
|
|
rotated, err := RotateBootstrapToken(ctx)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, first.DiscoveryToken, rotated.DiscoveryToken)
|
|
// 验证令牌已保存到 SystemConfig
|
|
savedToken, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyAgentDiscoveryToken)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, rotated.DiscoveryToken, savedToken.Value)
|
|
}
|
|
|
|
func TestValidateDiscoveryToken(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
bootstrap, err := GetBootstrapToken(ctx)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, ValidateDiscoveryToken(ctx, bootstrap.DiscoveryToken))
|
|
require.Error(t, ValidateDiscoveryToken(ctx, "invalid-token"))
|
|
}
|
|
|
|
func TestRequestAgentUpdateWithPreviewTag(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-update-agent"})
|
|
require.NoError(t, err)
|
|
|
|
originalClient := setReleaseHTTPClientForTest(&http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
expected := "https://api.github.com/repos/Rain-kl/OpenFlare/releases/tags/v0.5.0-rc.1"
|
|
require.Equal(t, expected, req.URL.String())
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.5.0-rc.1","prerelease":true}`)),
|
|
}, nil
|
|
}),
|
|
})
|
|
t.Cleanup(func() {
|
|
setReleaseHTTPClientForTest(originalClient)
|
|
})
|
|
|
|
updated, err := RequestAgentUpdate(ctx, created.ID, AgentUpdateInput{
|
|
Channel: "preview",
|
|
TagName: "v0.5.0-rc.1",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, updated.UpdateRequested)
|
|
assert.Equal(t, "preview", updated.UpdateChannel)
|
|
assert.Equal(t, "v0.5.0-rc.1", updated.UpdateTag)
|
|
}
|
|
|
|
func TestRequestOpenrestyRestart(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-restart"})
|
|
require.NoError(t, err)
|
|
|
|
updated, err := RequestOpenrestyRestart(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.True(t, updated.RestartOpenrestyRequested)
|
|
}
|
|
|
|
func seedActiveConfigVersion(t *testing.T, ctx context.Context) {
|
|
t.Helper()
|
|
conn := db.DB(ctx)
|
|
require.NotNil(t, conn)
|
|
require.NoError(t, conn.AutoMigrate(&model.ConfigVersion{}))
|
|
require.NoError(t, conn.Create(&model.ConfigVersion{
|
|
Version: "20260618-001",
|
|
SnapshotJSON: `{}`,
|
|
RenderedConfig: `server {}`,
|
|
Checksum: "abc123",
|
|
IsActive: true,
|
|
CreatedBy: "test",
|
|
}).Error)
|
|
}
|
|
|
|
func TestRequestForceSyncRequiresWebSocket(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
seedActiveConfigVersion(t, ctx)
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-sync"})
|
|
require.NoError(t, err)
|
|
|
|
_, err = RequestForceSync(ctx, created.ID)
|
|
require.Error(t, err)
|
|
assert.Equal(t, errNodeForceSyncFailed, err.Error())
|
|
}
|
|
|
|
func TestRequestForceSyncRequiresActiveConfig(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
conn := db.DB(ctx)
|
|
require.NotNil(t, conn)
|
|
require.NoError(t, conn.AutoMigrate(&model.ConfigVersion{}))
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-sync-active"})
|
|
require.NoError(t, err)
|
|
|
|
_, err = RequestForceSync(ctx, created.ID)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), errNoActiveConfigVersion)
|
|
}
|
|
|
|
func TestGetObservabilityStub(t *testing.T) {
|
|
cleanup := setupNodeTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := CreateNode(ctx, Input{Name: "edge-obs"})
|
|
require.NoError(t, err)
|
|
|
|
view, err := GetObservability(ctx, created.ID, ObservabilityQuery{Hours: 24, Limit: 50})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.NodeID, view.NodeID)
|
|
assert.Empty(t, view.MetricSnapshots)
|
|
}
|
|
|
|
func TestComputeNodeStatus(t *testing.T) {
|
|
now := time.Now()
|
|
pending := &model.OpenFlareNode{}
|
|
assert.Equal(t, nodeStatusPending, computeNodeStatus(pending))
|
|
|
|
online := &model.OpenFlareNode{LastSeenAt: &now}
|
|
assert.Equal(t, nodeStatusOnline, computeNodeStatus(online))
|
|
|
|
// computeNodeStatus 使用默认阈值 60 秒
|
|
offlineAt := now.Add(-61 * time.Second)
|
|
offline := &model.OpenFlareNode{LastSeenAt: &offlineAt}
|
|
assert.Equal(t, nodeStatusOffline, computeNodeStatus(offline))
|
|
}
|
|
|
|
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func TestCompareVersions(t *testing.T) {
|
|
tests := []struct {
|
|
local string
|
|
remote string
|
|
expected int
|
|
}{
|
|
{"v3.0.0-beta", "v3.0.0-beta.1", -1},
|
|
{"v3.0.0-beta", "v3.0.0", -1},
|
|
{"v3.0.0-beta.1", "v3.0.0", -1},
|
|
{"dev", "v3.0.0", -1},
|
|
{"v3.0.0", "v3.0.0", 0},
|
|
{"v3.0.0", "v2.9.9", 1},
|
|
{"v3.0.0", "v3.0.1", -1},
|
|
{"v3.0.0-beta.1", "v3.0.0-beta.2", -1},
|
|
{"v3.0.0-beta.11", "v3.0.0-beta.2", 1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.local+"_vs_"+tt.remote, func(t *testing.T) {
|
|
res := compareVersions(tt.local, tt.remote)
|
|
assert.Equal(t, tt.expected, res)
|
|
})
|
|
}
|
|
}
|