This commit is contained in:
ryan
2026-06-18 16:59:02 +08:00
parent e3bfd9ca6d
commit dfc480c73a
33 changed files with 3362 additions and 133 deletions
@@ -0,0 +1,177 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tasks
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
)
const (
// DatabaseCleanupTargetAccessLogs is the API cleanup target for access logs.
DatabaseCleanupTargetAccessLogs = "node_access_logs"
// DatabaseCleanupTargetMetricSnapshots is the API cleanup target for metric snapshots.
DatabaseCleanupTargetMetricSnapshots = "node_metric_snapshots"
// DatabaseCleanupTargetRequestReports is the API cleanup target for request reports.
DatabaseCleanupTargetRequestReports = "node_request_reports"
)
var databaseCleanupTargets = map[string]string{
DatabaseCleanupTargetAccessLogs: "访问日志",
DatabaseCleanupTargetMetricSnapshots: "性能快照",
DatabaseCleanupTargetRequestReports: "请求聚合",
}
func init() {
registerJob("database_auto_cleanup", "0 3 * * *", runDatabaseAutoCleanupJob)
}
// DatabaseCleanupInput describes a manual observability cleanup request.
type DatabaseCleanupInput struct {
Target string `json:"target"`
RetentionDays *int `json:"retention_days"`
}
// DatabaseCleanupResult summarizes a manual observability cleanup run.
type DatabaseCleanupResult struct {
Target string `json:"target"`
TargetLabel string `json:"target_label"`
DeletedCount int64 `json:"deleted_count"`
DeleteAll bool `json:"delete_all"`
RetentionDays *int `json:"retention_days,omitempty"`
Cutoff *time.Time `json:"cutoff,omitempty"`
}
// DatabaseAutoCleanupSummary summarizes a scheduled auto-cleanup run.
type DatabaseAutoCleanupSummary struct {
RetentionDays int `json:"retention_days"`
ExecutedAt time.Time `json:"executed_at"`
Results []DatabaseCleanupResult `json:"results"`
}
// CleanupDatabaseObservability deletes observability rows for the given target.
func CleanupDatabaseObservability(ctx context.Context, input DatabaseCleanupInput) (*DatabaseCleanupResult, error) {
target := strings.TrimSpace(input.Target)
targetLabel, ok := databaseCleanupTargets[target]
if !ok {
return nil, errors.New("unsupported cleanup target")
}
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
return nil, errors.New("retention_days 必须为大于 0 的整数")
}
result := &DatabaseCleanupResult{
Target: target,
TargetLabel: targetLabel,
DeleteAll: input.RetentionDays == nil,
}
if input.RetentionDays == nil {
deleted, err := deleteAllObservabilityRows(ctx, target)
if err != nil {
return nil, err
}
result.DeletedCount = deleted
return result, nil
}
retentionDays := *input.RetentionDays
cutoff := time.Now().UTC().Add(-time.Duration(retentionDays) * 24 * time.Hour)
deleted, err := deleteObservabilityRowsBefore(ctx, target, cutoff)
if err != nil {
return nil, err
}
result.DeletedCount = deleted
result.RetentionDays = &retentionDays
result.Cutoff = &cutoff
return result, nil
}
// RunDatabaseAutoCleanupOnce runs retention-based cleanup for all observability targets.
func RunDatabaseAutoCleanupOnce(now time.Time) (*DatabaseAutoCleanupSummary, error) {
if !model.DatabaseAutoCleanupEnabled {
return nil, nil
}
if model.DatabaseAutoCleanupRetentionDays < 1 {
return nil, fmt.Errorf("database auto cleanup retention_days must be at least 1")
}
retentionDays := model.DatabaseAutoCleanupRetentionDays
ctx := context.Background()
results := make([]DatabaseCleanupResult, 0, len(databaseCleanupTargets))
for _, target := range []string{
DatabaseCleanupTargetAccessLogs,
DatabaseCleanupTargetMetricSnapshots,
DatabaseCleanupTargetRequestReports,
} {
result, err := CleanupDatabaseObservability(ctx, DatabaseCleanupInput{
Target: target,
RetentionDays: &retentionDays,
})
if err != nil {
return nil, err
}
results = append(results, *result)
}
return &DatabaseAutoCleanupSummary{
RetentionDays: retentionDays,
ExecutedAt: now.UTC(),
Results: results,
}, nil
}
func runDatabaseAutoCleanupJob(ctx context.Context) {
summary, err := RunDatabaseAutoCleanupOnce(time.Now())
if err != nil {
logger.ErrorF(ctx, "[OpenFlareTasks] database auto cleanup failed: %v", err)
return
}
if summary == nil {
return
}
totalDeleted := int64(0)
for _, item := range summary.Results {
totalDeleted += item.DeletedCount
}
logger.InfoF(
ctx,
"[OpenFlareTasks] database auto cleanup completed retention_days=%d deleted_count=%d",
summary.RetentionDays,
totalDeleted,
)
}
func deleteAllObservabilityRows(ctx context.Context, target string) (int64, error) {
switch target {
case DatabaseCleanupTargetAccessLogs:
return model.DeleteAllOpenFlareAccessLogs(ctx)
case DatabaseCleanupTargetMetricSnapshots:
return model.DeleteAllOpenFlareMetricSnapshots(ctx)
case DatabaseCleanupTargetRequestReports:
return model.DeleteAllOpenFlareRequestReports(ctx)
default:
return 0, errors.New("unsupported cleanup target")
}
}
func deleteObservabilityRowsBefore(ctx context.Context, target string, cutoff time.Time) (int64, error) {
switch target {
case DatabaseCleanupTargetAccessLogs:
return model.DeleteOpenFlareAccessLogsBefore(ctx, cutoff)
case DatabaseCleanupTargetMetricSnapshots:
return model.DeleteOpenFlareMetricSnapshotsBefore(ctx, cutoff)
case DatabaseCleanupTargetRequestReports:
return model.DeleteOpenFlareRequestReportsBefore(ctx, cutoff)
default:
return 0, errors.New("unsupported cleanup target")
}
}
@@ -0,0 +1,150 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tasks
import (
"context"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupDatabaseCleanupTestDB(t *testing.T) context.Context {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.OpenFlareAccessLog{},
&model.OpenFlareMetricSnapshot{},
&model.OpenFlareRequestReport{},
))
db.SetDB(sqliteDB)
t.Cleanup(func() {
db.SetDB(nil)
})
return context.Background()
}
func TestCleanupDatabaseObservabilityDeletesTargetedRows(t *testing.T) {
ctx := setupDatabaseCleanupTestDB(t)
now := time.Now().UTC()
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-10 * 24 * time.Hour),
CPUUsagePercent: 10,
}).Error)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-12 * time.Hour),
CPUUsagePercent: 20,
}).Error)
retentionDays := 7
result, err := CleanupDatabaseObservability(ctx, DatabaseCleanupInput{
Target: DatabaseCleanupTargetMetricSnapshots,
RetentionDays: &retentionDays,
})
require.NoError(t, err)
assert.False(t, result.DeleteAll)
assert.Equal(t, int64(1), result.DeletedCount)
rows, err := model.ListOpenFlareMetricSnapshotsSince(ctx, "", time.Time{}, 0)
require.NoError(t, err)
require.Len(t, rows, 1)
assert.Equal(t, float64(20), rows[0].CPUUsagePercent)
}
func TestCleanupDatabaseObservabilityDeletesAllRowsWhenRetentionMissing(t *testing.T) {
ctx := setupDatabaseCleanupTestDB(t)
now := time.Now().UTC()
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-3 * time.Hour),
RemoteAddr: "203.0.113.1",
Host: "example.com",
Path: "/one",
StatusCode: 200,
}).Error)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-2 * time.Hour),
RemoteAddr: "203.0.113.2",
Host: "example.com",
Path: "/two",
StatusCode: 502,
}).Error)
result, err := CleanupDatabaseObservability(ctx, DatabaseCleanupInput{
Target: DatabaseCleanupTargetAccessLogs,
})
require.NoError(t, err)
assert.True(t, result.DeleteAll)
assert.Equal(t, int64(2), result.DeletedCount)
rows, err := model.ListOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{Page: 0, PageSize: 10})
require.NoError(t, err)
assert.Empty(t, rows)
}
func TestRunDatabaseAutoCleanupOnceDeletesAllObservabilityTargets(t *testing.T) {
ctx := setupDatabaseCleanupTestDB(t)
now := time.Now().UTC()
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-48 * time.Hour),
RemoteAddr: "203.0.113.10",
Host: "example.com",
Path: "/access",
StatusCode: 200,
}).Error)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-48 * time.Hour),
CPUUsagePercent: 10,
}).Error)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareRequestReport{
NodeID: "node-a",
WindowStartedAt: now.Add(-49 * time.Hour),
WindowEndedAt: now.Add(-48 * time.Hour),
RequestCount: 15,
}).Error)
previousEnabled := model.DatabaseAutoCleanupEnabled
previousRetentionDays := model.DatabaseAutoCleanupRetentionDays
model.DatabaseAutoCleanupEnabled = true
model.DatabaseAutoCleanupRetentionDays = 1
t.Cleanup(func() {
model.DatabaseAutoCleanupEnabled = previousEnabled
model.DatabaseAutoCleanupRetentionDays = previousRetentionDays
})
summary, err := RunDatabaseAutoCleanupOnce(now)
require.NoError(t, err)
require.NotNil(t, summary)
require.Len(t, summary.Results, 3)
accessLogs, err := model.ListOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{Page: 0, PageSize: 10})
require.NoError(t, err)
assert.Empty(t, accessLogs)
metricSnapshots, err := model.ListOpenFlareMetricSnapshotsSince(ctx, "", time.Time{}, 0)
require.NoError(t, err)
assert.Empty(t, metricSnapshots)
requestReports, err := model.ListOpenFlareRequestReportsSince(ctx, "", time.Time{}, 0)
require.NoError(t, err)
assert.Empty(t, requestReports)
}
@@ -0,0 +1,15 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package tasks is the single home for OpenFlare scheduled and background work
// that runs inside the API process (goroutines plus robfig/cron), not the Asynq
// worker or scheduler.
//
// Each job lives in its own file and registers via registerJob in init(). This
// layout is intentional so jobs can migrate to a future task framework without
// changing call sites: swap the registry implementation while keeping per-job files.
//
// Wire-up: bootstrap.RegisterOpenFlareBackgroundTasks imports this package so
// init() registrations run; bootstrap.Init starts the cron scheduler when the
// process serves the HTTP API.
package tasks
@@ -0,0 +1,97 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tasks
import (
"context"
"sync"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/robfig/cron/v3"
)
type cronJob struct {
name string
spec string
run func(context.Context)
}
var (
registryMu sync.Mutex
started bool
cronJobs []cronJob
cronRunner *cron.Cron
jobCtx context.Context
jobCancel context.CancelFunc
)
func registerJob(name, cronSpec string, fn func(context.Context)) {
registryMu.Lock()
defer registryMu.Unlock()
cronJobs = append(cronJobs, cronJob{name: name, spec: cronSpec, run: fn})
}
// RegisterCronJob registers a cron job from another OpenFlare package (for example waf).
// Prefer registerJob from init() inside this package when possible.
func RegisterCronJob(name, cronSpec string, fn func(context.Context)) {
registerJob(name, cronSpec, fn)
}
// LogJobError records a failed OpenFlare cron job run.
func LogJobError(ctx context.Context, name string, err error) {
logger.ErrorF(ctx, "[OpenFlareTasks] %s failed: %v", name, err)
}
// Start launches the cron scheduler in a background goroutine. Safe to call multiple times.
func Start(ctx context.Context) {
registryMu.Lock()
defer registryMu.Unlock()
if started {
return
}
jobCtx, jobCancel = context.WithCancel(context.Background())
runner := cron.New()
for _, job := range cronJobs {
current := job
if _, err := runner.AddFunc(current.spec, func() {
current.run(jobCtx)
}); err != nil {
logger.ErrorF(ctx, "[OpenFlareTasks] register cron job %q failed: %v", current.name, err)
continue
}
logger.InfoF(ctx, "[OpenFlareTasks] registered cron job %q (%s)", current.name, current.spec)
}
runner.Start()
cronRunner = runner
started = true
}
// Stop shuts down the cron scheduler gracefully and cancels job contexts.
func Stop() {
registryMu.Lock()
defer registryMu.Unlock()
if !started || cronRunner == nil {
return
}
stopCtx := cronRunner.Stop()
<-stopCtx.Done()
if jobCancel != nil {
jobCancel()
}
cronRunner = nil
started = false
jobCtx = nil
jobCancel = nil
}
// ResetRegistryForTest clears scheduler state so unit tests can call Start again.
func ResetRegistryForTest() {
Stop()
registryMu.Lock()
defer registryMu.Unlock()
cronJobs = nil
}
@@ -0,0 +1,51 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tasks
import (
"context"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
)
func init() {
registerJob("ssl_renew", "0 0 * * *", func(ctx context.Context) {
if err := runSSLRenewJob(ctx); err != nil {
LogJobError(ctx, "ssl_renew", err)
}
})
}
func runSSLRenewJob(ctx context.Context) error {
logger.InfoF(ctx, "[OpenFlareTasks] SSL renew job started")
certificates, err := model.ListTLSCertificates(ctx)
if err != nil {
logger.ErrorF(ctx, "[OpenFlareTasks] list certificates failed: %v", err)
return err
}
now := time.Now()
due := tls.CertificatesDueForRenewal(certificates, now)
if len(due) == 0 {
logger.InfoF(ctx, "[OpenFlareTasks] SSL renew job completed: no certificates due")
return nil
}
var triggered int
for _, cert := range due {
logger.InfoF(ctx, "[OpenFlareTasks] renewing certificate id=%d domain=%s", cert.ID, cert.PrimaryDomain)
if _, err := tls.RenewCertificate(ctx, cert.ID); err != nil {
logger.ErrorF(ctx, "[OpenFlareTasks] renew certificate id=%d domain=%s failed: %v", cert.ID, cert.PrimaryDomain, err)
continue
}
triggered++
}
logger.InfoF(ctx, "[OpenFlareTasks] SSL renew job completed: triggered=%d eligible=%d", triggered, len(due))
return nil
}
@@ -0,0 +1,76 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tasks
import (
"context"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupSSLRenewTestDB(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.TLSCertificate{}))
db.SetDB(sqliteDB)
oldSecret := config.Config.App.SessionSecret
config.Config.App.SessionSecret = "test_session_secret_for_ssl_renew"
return func() {
db.SetDB(nil)
config.Config.App.SessionSecret = oldSecret
}
}
func TestRunSSLRenewJobTriggersDueCertificates(t *testing.T) {
cleanup := setupSSLRenewTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
due := &model.TLSCertificate{
Name: "due-cert",
Provider: "acme",
AutoRenew: true,
ApplyStatus: "ready",
PrimaryDomain: "due.example.com",
CertPEM: " ",
KeyPEM: " ",
NotAfter: now.Add(2 * 24 * time.Hour),
}
fresh := &model.TLSCertificate{
Name: "fresh-cert",
Provider: "acme",
AutoRenew: true,
ApplyStatus: "ready",
PrimaryDomain: "fresh.example.com",
CertPEM: " ",
KeyPEM: " ",
NotAfter: now.Add(30 * 24 * time.Hour),
}
require.NoError(t, model.CreateTLSCertificateRecord(ctx, due))
require.NoError(t, model.CreateTLSCertificateRecord(ctx, fresh))
require.NoError(t, runSSLRenewJob(ctx))
renewed, err := model.GetTLSCertificateByID(ctx, due.ID)
require.NoError(t, err)
assert.Equal(t, "applying", renewed.ApplyStatus)
unchanged, err := model.GetTLSCertificateByID(ctx, fresh.ID)
require.NoError(t, err)
assert.Equal(t, "ready", unchanged.ApplyStatus)
}
@@ -0,0 +1,53 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tasks
import (
"context"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/uptimekuma"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
)
var (
lastUptimeKumaSyncTime time.Time
uptimeKumaSyncMutex sync.Mutex
)
func init() {
registerJob("uptime_kuma_sync", "* * * * *", runUptimeKumaSyncJob)
}
func runUptimeKumaSyncJob(ctx context.Context) {
if !model.UptimeKumaEnabled {
return
}
interval := model.UptimeKumaSyncInterval
if interval <= 0 {
interval = 5
}
if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute {
return
}
if !uptimeKumaSyncMutex.TryLock() {
logger.WarnF(ctx, "[OpenFlareTasks] Uptime Kuma sync job is already running, skipping this scheduled run")
return
}
defer uptimeKumaSyncMutex.Unlock()
logger.InfoF(ctx, "[OpenFlareTasks] Starting scheduled Uptime Kuma sync")
if err := uptimekuma.SyncToUptimeKuma(ctx); err != nil {
logger.ErrorF(ctx, "[OpenFlareTasks] Uptime Kuma sync failed: %v", err)
return
}
lastUptimeKumaSyncTime = time.Now()
logger.InfoF(ctx, "[OpenFlareTasks] Uptime Kuma sync completed successfully")
}
@@ -0,0 +1,15 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tasks
import "context"
// RegisterWAFIPGroupSync registers the WAF IP group sync cron job without importing waf.
func RegisterWAFIPGroupSync(syncFn func(context.Context) error) {
RegisterCronJob("waf_ip_group_sync", "@every 5m", func(ctx context.Context) {
if err := syncFn(ctx); err != nil {
LogJobError(ctx, "waf_ip_group_sync", err)
}
})
}