Files
OpenFlare/internal/apps/openflare/pages/source_runtime.go
T
ryan abe8fb8268 refactor(pages): 精简部署源模型并重构详情页交互
将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段;
Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
2026-07-19 20:23:31 +08:00

372 lines
12 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
pagesSourceCheckLeaseDuration = 2 * time.Minute
pagesSourceSyncLeaseDuration = 15 * time.Minute
sourceLeaseTokenBytes = 32
sourceRuntimeErrorMaxBytes = 512
sourceRevisionHexLength = 64
sourceColumnAutoUpdateEnabled = "auto_update_enabled"
sourceColumnConfigVersion = "config_version"
sourceRuntimeColumnSyncStatus = "sync_status"
sourceRuntimeColumnLastError = "last_error"
sourceRuntimeColumnLastCheckedAt = "last_checked_at"
sourceRuntimeColumnNextCheckAt = "next_check_at"
sourceRuntimeColumnLeaseToken = "lease_token"
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
pagesDeploymentColumnStatus = "status"
)
type sourceLeaseOutcome string
const (
sourceLeaseAcquired sourceLeaseOutcome = "acquired"
sourceLeaseBusy sourceLeaseOutcome = "busy"
sourceLeaseStale sourceLeaseOutcome = "stale"
)
// sourceExecutionSnapshot captures every mutable value that can affect archive
// validation or the atomic activation decision. The queued payload deliberately
// does not carry project content configuration.
type sourceExecutionSnapshot struct {
ProjectID uint
SourceID uint
SourceConfigVersion int
ContentConfigVersion int
SourceType string
SourceIdentity string
RemoteURL string
AllowInsecure bool
GitHubRepository string
ReleaseSelector string
ReleaseTag string
AssetName string
AutoUpdateEnabled bool
CheckIntervalMinutes int
ETag string
LastSeenRevision string
LastSeenDetail string
LastAppliedRevision string
LastAppliedDetail string
RootDir string
EntryFile string
LeaseToken string
LeaseExpiresAt time.Time
}
func acquireSourceLease(
ctx context.Context,
sourceID uint,
expectedConfigVersion int,
action string,
) (*sourceExecutionSnapshot, sourceLeaseOutcome, error) {
leaseDuration, status, err := sourceLeaseParameters(action)
if err != nil {
return nil, sourceLeaseStale, err
}
token, err := newSourceLeaseToken()
if err != nil {
return nil, sourceLeaseStale, err
}
now := time.Now()
expiresAt := now.Add(leaseDuration)
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", sourceID).
Where("lease_expires_at IS NULL OR lease_expires_at <= ?", now).
Where(
"EXISTS (SELECT 1 FROM of_pages_project_sources source WHERE source.id = ? AND source.config_version = ?)",
sourceID,
expectedConfigVersion,
).
Updates(map[string]any{
sourceRuntimeColumnLeaseToken: token,
sourceRuntimeColumnLeaseExpiresAt: expiresAt,
sourceRuntimeColumnSyncStatus: status,
sourceRuntimeColumnLastError: "",
})
if result.Error != nil {
return nil, sourceLeaseStale, result.Error
}
if result.RowsAffected == 0 {
outcome, inspectErr := inspectSourceLeaseMiss(ctx, sourceID, expectedConfigVersion, now)
return nil, outcome, inspectErr
}
snapshot, err := loadSourceExecutionSnapshot(ctx, sourceID, token)
if err != nil {
if errors.Is(err, errSourceLeaseSnapshotStale) {
return nil, sourceLeaseStale, nil
}
return nil, sourceLeaseStale, err
}
return snapshot, sourceLeaseAcquired, nil
}
var errSourceLeaseSnapshotStale = errors.New("source lease snapshot stale")
func loadSourceExecutionSnapshot(
ctx context.Context,
sourceID uint,
token string,
) (*sourceExecutionSnapshot, error) {
var snapshot sourceExecutionSnapshot
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var source model.PagesProjectSource
if err := tx.Where("id = ?", sourceID).First(&source).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errSourceLeaseSnapshotStale
}
return err
}
var project model.PagesProject
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
First(&project, source.ProjectID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errSourceLeaseSnapshotStale
}
return err
}
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("id = ?", sourceID).
First(&source).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errSourceLeaseSnapshotStale
}
return err
}
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", source.ID).
First(&runtime).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errSourceLeaseSnapshotStale
}
return err
}
// 必须在 runtime 行锁拿到后重新取时间,避免锁等待跨过
// lease expiry 时仍使用事务开始前的旧时间继续执行。
now := time.Now()
if runtime.LeaseToken != token || runtime.LeaseExpiresAt == nil || !runtime.LeaseExpiresAt.After(now) {
return errSourceLeaseSnapshotStale
}
snapshot = sourceExecutionSnapshot{
ProjectID: project.ID,
SourceID: source.ID,
SourceConfigVersion: source.ConfigVersion,
ContentConfigVersion: project.ContentConfigVersion,
SourceType: source.SourceType,
SourceIdentity: source.SourceIdentity,
RemoteURL: source.RemoteURL,
AllowInsecure: source.AllowInsecure,
GitHubRepository: source.GitHubRepository,
ReleaseSelector: source.ReleaseSelector,
ReleaseTag: source.ReleaseTag,
AssetName: source.AssetName,
AutoUpdateEnabled: source.AutoUpdateEnabled,
CheckIntervalMinutes: source.CheckIntervalMinutes,
ETag: runtime.ETag,
LastSeenRevision: runtime.LastSeenRevision,
LastSeenDetail: runtime.LastSeenDetail,
LastAppliedRevision: runtime.LastAppliedRevision,
LastAppliedDetail: runtime.LastAppliedDetail,
RootDir: project.RootDir,
EntryFile: project.EntryFile,
LeaseToken: token,
LeaseExpiresAt: *runtime.LeaseExpiresAt,
}
return nil
})
if err != nil {
return nil, err
}
return &snapshot, nil
}
func inspectSourceLeaseMiss(
ctx context.Context,
sourceID uint,
expectedConfigVersion int,
now time.Time,
) (sourceLeaseOutcome, error) {
var source model.PagesProjectSource
if err := db.DB(ctx).Where("id = ?", sourceID).First(&source).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return sourceLeaseStale, nil
}
return sourceLeaseStale, err
}
if source.ConfigVersion != expectedConfigVersion {
return sourceLeaseStale, nil
}
var runtime model.PagesProjectSourceRuntime
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return sourceLeaseStale, nil
}
return sourceLeaseStale, err
}
if runtime.LeaseExpiresAt != nil && runtime.LeaseExpiresAt.After(now) {
return sourceLeaseBusy, nil
}
return sourceLeaseStale, nil
}
func sourceLeaseParameters(action string) (time.Duration, string, error) {
switch action {
case sourceActionCheck:
return pagesSourceCheckLeaseDuration, pagesSourceStatusChecking, nil
case sourceActionSync:
return pagesSourceSyncLeaseDuration, pagesSourceStatusSyncing, nil
default:
return 0, "", errors.New(errPagesSourceActionInvalid)
}
}
func newSourceLeaseToken() (string, error) {
value := make([]byte, sourceLeaseTokenBytes)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}
func renewSourceLease(ctx context.Context, snapshot *sourceExecutionSnapshot, duration time.Duration) (bool, error) {
if snapshot == nil || snapshot.SourceID == 0 || snapshot.LeaseToken == "" || duration <= 0 {
return false, errors.New(errPagesSourceLeaseLost)
}
now := time.Now()
expiresAt := now.Add(duration)
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
Updates(map[string]any{sourceRuntimeColumnLeaseExpiresAt: expiresAt})
if result.Error != nil {
return false, result.Error
}
if result.RowsAffected == 0 {
return false, nil
}
snapshot.LeaseExpiresAt = expiresAt
return true, nil
}
func failSourceLease(ctx context.Context, snapshot *sourceExecutionSnapshot, message string) error {
if snapshot == nil || snapshot.SourceID == 0 || snapshot.LeaseToken == "" {
return nil
}
message = safeSourceRuntimeError(message)
now := time.Now()
return db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
Updates(map[string]any{
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
sourceRuntimeColumnLastError: message,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
}).Error
}
func safeSourceRuntimeError(message string) string {
message = strings.TrimSpace(message)
if message == "" {
return errPagesSourceSyncFailed
}
if len(message) > sourceRuntimeErrorMaxBytes {
message = message[:sourceRuntimeErrorMaxBytes]
}
return message
}
func sourceLeaseIsBusy(ctx context.Context, sourceID uint) (bool, error) {
var runtime model.PagesProjectSourceRuntime
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
return false, err
}
return runtime.LeaseExpiresAt != nil && runtime.LeaseExpiresAt.After(time.Now()), nil
}
// recoverExpiredSourceLease clears one exact expired lease owner. Matching the
// token, observed expiry and status prevents a scanner from overwriting a
// worker that renewed or was replaced after the candidate query.
func recoverExpiredSourceLease(
ctx context.Context,
sourceID uint,
token string,
expiresAt time.Time,
status string,
now time.Time,
nextCheckAt *time.Time,
) (bool, error) {
if sourceID == 0 || token == "" ||
(status != pagesSourceStatusChecking && status != pagesSourceStatusSyncing) {
return false, nil
}
updates := map[string]any{
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
sourceRuntimeColumnLastError: errPagesSourceLeaseExpired,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
sourceRuntimeColumnNextCheckAt: nextCheckAt,
}
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", sourceID).
Where("lease_token = ?", token).
Where("lease_expires_at = ? AND lease_expires_at <= ?", expiresAt, now).
Where("sync_status = ?", status).
Updates(updates)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected == 1, nil
}
// fenceAndNormalizeRuntime invalidates in-flight work while preserving safe
// seen/applied cursors. The caller must already hold the source row lock.
func fenceAndNormalizeRuntime(tx *gorm.DB, sourceID uint) error {
var runtime model.PagesProjectSourceRuntime
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("source_id = ?", sourceID).
First(&runtime).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return tx.Model(&runtime).Updates(map[string]any{
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
sourceRuntimeColumnSyncStatus: normalizedSourceRuntimeStatus(&runtime),
}).Error
}
func sourceHasSameReleaseReplacement(runtime *model.PagesProjectSourceRuntime) bool {
if runtime == nil || runtime.LastSeenRevision == "" || runtime.LastSeenRevision == runtime.LastAppliedRevision {
return false
}
seen := sourceDetail{}
applied := sourceDetail{}
if unmarshalSourceDetail(runtime.LastSeenDetail, &seen) != nil ||
unmarshalSourceDetail(runtime.LastAppliedDetail, &applied) != nil {
return false
}
return seen.ReleaseID != "" && seen.ReleaseID == applied.ReleaseID
}