mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
feat(pages): 支持 Remote 部署源同步
新增部署源配置与运行态模型、安全下载、租约续期、原子激活和失败补偿。 接入内部任务与脱敏前端交互,并阻止数据库 Trace 和日志展开敏感查询参数。
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/task"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
pagesSourceTriggerManualSync = "manual_sync"
|
||||
pagesSourceCreatedBySystem = "system:pages-source-sync"
|
||||
pagesSourceHeartbeatInterval = pagesSourceSyncLeaseDuration / 3
|
||||
pagesSourceCleanupTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errSourceFinalFence = errors.New("pages source final fence rejected")
|
||||
errSourceLeaseHeartbeatLost = errors.New("pages source lease heartbeat lost")
|
||||
sourceCommitNow = time.Now
|
||||
)
|
||||
|
||||
type sourceSyncOutcome struct {
|
||||
Deployment *DeploymentView
|
||||
Reused bool
|
||||
Stale bool
|
||||
}
|
||||
|
||||
type preparedRemoteSource struct {
|
||||
Candidate *SourceCandidate
|
||||
Manifest *deploymentManifest
|
||||
Detail sourceDetail
|
||||
DetailJSON string
|
||||
}
|
||||
|
||||
type sourceIngestState struct {
|
||||
Result upload.IngestResult
|
||||
HasIngest bool
|
||||
Referenced bool
|
||||
}
|
||||
|
||||
type sourceCommitState struct {
|
||||
Project *model.PagesProject
|
||||
Source *model.PagesProjectSource
|
||||
Runtime *model.PagesProjectSourceRuntime
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type sourceLeaseHeartbeat struct {
|
||||
cancel context.CancelFunc
|
||||
done <-chan error
|
||||
stopOnce sync.Once
|
||||
stopErr error
|
||||
}
|
||||
|
||||
func startSourceLeaseHeartbeat(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
leaseDuration time.Duration,
|
||||
interval time.Duration,
|
||||
) (context.Context, *sourceLeaseHeartbeat, error) {
|
||||
if snapshot == nil || leaseDuration <= 0 || interval <= 0 || interval >= leaseDuration {
|
||||
return nil, nil, errors.New(errPagesSourceLeaseLost)
|
||||
}
|
||||
renewed, err := renewSourceLease(ctx, snapshot, leaseDuration)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !renewed {
|
||||
return nil, nil, errSourceLeaseHeartbeatLost
|
||||
}
|
||||
|
||||
workCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
heartbeat := &sourceLeaseHeartbeat{cancel: cancel, done: done}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-workCtx.Done():
|
||||
done <- nil
|
||||
return
|
||||
case <-ticker.C:
|
||||
renewed, renewErr := renewSourceLease(workCtx, snapshot, leaseDuration)
|
||||
if renewErr != nil {
|
||||
if workCtx.Err() != nil {
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
done <- renewErr
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if !renewed {
|
||||
done <- errSourceLeaseHeartbeatLost
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return workCtx, heartbeat, nil
|
||||
}
|
||||
|
||||
func (heartbeat *sourceLeaseHeartbeat) stop() error {
|
||||
if heartbeat == nil {
|
||||
return nil
|
||||
}
|
||||
heartbeat.stopOnce.Do(func() {
|
||||
heartbeat.cancel()
|
||||
heartbeat.stopErr = <-heartbeat.done
|
||||
})
|
||||
return heartbeat.stopErr
|
||||
}
|
||||
|
||||
func sourceHeartbeatOutcome(err error) (*sourceSyncOutcome, error) {
|
||||
if errors.Is(err, errSourceLeaseHeartbeatLost) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func recordSourceLeaseFailure(ctx context.Context, snapshot *sourceExecutionSnapshot) {
|
||||
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||
defer cancel()
|
||||
if err := failSourceLease(cleanupCtx, snapshot, errPagesSourceSyncFailed); err != nil {
|
||||
var sourceID uint
|
||||
if snapshot != nil {
|
||||
sourceID = snapshot.SourceID
|
||||
}
|
||||
logger.WarnF(cleanupCtx, "[PagesSource] record failed runtime state failed: source_id=%d error=%v", sourceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), pagesSourceCleanupTimeout)
|
||||
}
|
||||
|
||||
func syncRemoteSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
) (outcome *sourceSyncOutcome, resultErr error) {
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeRemoteURL {
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
recordSourceLeaseFailure(ctx, snapshot)
|
||||
}
|
||||
}()
|
||||
|
||||
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
|
||||
ctx,
|
||||
snapshot,
|
||||
pagesSourceSyncLeaseDuration,
|
||||
pagesSourceHeartbeatInterval,
|
||||
)
|
||||
if err != nil {
|
||||
return sourceHeartbeatOutcome(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = heartbeat.stop()
|
||||
}()
|
||||
|
||||
limits := resolvePagesLimits(workCtx)
|
||||
prepared, err := prepareRemoteSource(workCtx, snapshot, limits)
|
||||
if err != nil {
|
||||
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||
return sourceHeartbeatOutcome(heartbeatErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := prepared.Candidate.Cleanup(); cleanupErr != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup temporary package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||
}
|
||||
}()
|
||||
|
||||
ingestState, err := resolveSourceIngest(workCtx, snapshot, prepared)
|
||||
if err != nil {
|
||||
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||
return sourceHeartbeatOutcome(heartbeatErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
compensateSourceIngest(ctx, snapshot, ingestState)
|
||||
}()
|
||||
|
||||
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||
return sourceHeartbeatOutcome(heartbeatErr)
|
||||
}
|
||||
renewed, err := renewSourceLease(ctx, snapshot, pagesSourceSyncLeaseDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !renewed {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "[activate] 正在原子切换生产部署")
|
||||
deployment, reused, referenced, err := commitSourceDeployment(
|
||||
ctx,
|
||||
snapshot,
|
||||
prepared.Candidate.Checksum,
|
||||
prepared.Detail,
|
||||
prepared.DetailJSON,
|
||||
actor,
|
||||
prepared.Manifest,
|
||||
ingestState.Result,
|
||||
ingestState.HasIngest,
|
||||
)
|
||||
ingestState.Referenced = referenced
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ingestState.Referenced = ingestState.HasIngest && deployment.UploadID == ingestState.Result.Upload.ID
|
||||
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, limits.HistoryCount, 0); pruneErr != nil {
|
||||
logger.ErrorF(ctx,
|
||||
"[PagesSource] strict prune failed: project_id=%d source_id=%d keep=%d error=%v",
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceID,
|
||||
limits.HistoryCount,
|
||||
pruneErr,
|
||||
)
|
||||
}
|
||||
view := buildDeploymentView(deployment)
|
||||
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
|
||||
}
|
||||
|
||||
func prepareRemoteSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
limits pagesLimits,
|
||||
) (*preparedRemoteSource, error) {
|
||||
task.AppendLog(ctx, "[download] 正在获取远程部署包")
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: snapshot.RemoteURL,
|
||||
NetworkPolicy: snapshot.RemoteNetworkPolicy,
|
||||
MaxPackageBytes: limits.PackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if candidate == nil || candidate.TempPath == "" || candidate.Checksum == "" || candidate.Format == "" {
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[verify] 正在校验归档结构与入口文件")
|
||||
manifest, err := inspectPagesPackage(candidate.TempPath, candidate.Format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: safeRemoteSourceLabel(candidate.SafeLabel)}
|
||||
detailJSON, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
return &preparedRemoteSource{
|
||||
Candidate: candidate,
|
||||
Manifest: manifest,
|
||||
Detail: detail,
|
||||
DetailJSON: string(detailJSON),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cleanupFailedRemoteCandidate(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
candidate *SourceCandidate,
|
||||
) {
|
||||
if err := candidate.Cleanup(); err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup failed preparation package: source_id=%d error=%v", snapshot.SourceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSourceIngest(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
prepared *preparedRemoteSource,
|
||||
) (*sourceIngestState, error) {
|
||||
_, err := findSourceDeployment(
|
||||
ctx,
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceIdentity,
|
||||
prepared.Candidate.Checksum,
|
||||
)
|
||||
if err == nil {
|
||||
return &sourceIngestState{}, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[ingest] 正在保存受管部署包")
|
||||
result, err := ingestPagesDeploymentPackageWithSource(
|
||||
ctx,
|
||||
prepared.Candidate.TempPath,
|
||||
prepared.Candidate.Checksum,
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceID,
|
||||
prepared.Detail.Label,
|
||||
prepared.Candidate.Format,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sourceIngestState{Result: result, HasIngest: true}, nil
|
||||
}
|
||||
|
||||
func compensateSourceIngest(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
state *sourceIngestState,
|
||||
) {
|
||||
if state == nil || !state.HasIngest || !state.Result.Created || state.Referenced {
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||
defer cancel()
|
||||
task.AppendLog(cleanupCtx, "[cleanup] 正在补偿未引用的部署包记录")
|
||||
if err := removePagesUploadIfUnreferenced(cleanupCtx, snapshot.ProjectID, state.Result.Upload.ID); err != nil {
|
||||
logger.ErrorF(cleanupCtx,
|
||||
"[PagesSource] compensate upload failed: project_id=%d source_id=%d upload_id=%d error=%v",
|
||||
snapshot.ProjectID, snapshot.SourceID, state.Result.Upload.ID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func findSourceDeployment(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
sourceIdentity string,
|
||||
revision string,
|
||||
) (*model.PagesDeployment, error) {
|
||||
var deployment model.PagesDeployment
|
||||
err := db.DB(ctx).
|
||||
Where("project_id = ? AND source_identity = ? AND source_revision = ?", projectID, sourceIdentity, revision).
|
||||
First(&deployment).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &deployment, nil
|
||||
}
|
||||
|
||||
func commitSourceDeployment(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
revision string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult upload.IngestResult,
|
||||
hasIngest bool,
|
||||
) (*model.PagesDeployment, bool, bool, error) {
|
||||
if snapshot == nil || manifest == nil {
|
||||
return nil, false, false, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
var committed model.PagesDeployment
|
||||
reused := false
|
||||
ingestReferenced := false
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
state, err := lockSourceCommitState(tx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, targetReused, err := resolveSourceDeploymentTx(
|
||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := lockSourceDeploymentUploadsTx(tx, target, ingestResult, hasIngest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDeploymentEntry(tx, target.ID, state.Project.RootDir, state.Project.EntryFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = *target
|
||||
reused = targetReused
|
||||
ingestReferenced = hasIngest && target.UploadID == ingestResult.Upload.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
return &committed, reused, ingestReferenced, nil
|
||||
}
|
||||
|
||||
func lockSourceCommitState(tx *gorm.DB, snapshot *sourceExecutionSnapshot) (*sourceCommitState, error) {
|
||||
state := &sourceCommitState{}
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
First(&project, snapshot.ProjectID).Error; err != nil {
|
||||
return nil, sourceFenceRecordError(err)
|
||||
}
|
||||
if project.ContentConfigVersion != snapshot.ContentConfigVersion {
|
||||
return nil, errSourceFinalFence
|
||||
}
|
||||
var source model.PagesProjectSource
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("id = ? AND project_id = ?", snapshot.SourceID, snapshot.ProjectID).
|
||||
First(&source).Error; err != nil {
|
||||
return nil, sourceFenceRecordError(err)
|
||||
}
|
||||
if source.ConfigVersion != snapshot.SourceConfigVersion ||
|
||||
source.SourceIdentity != snapshot.SourceIdentity ||
|
||||
source.SourceType != snapshot.SourceType {
|
||||
return nil, errSourceFinalFence
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("source_id = ?", source.ID).
|
||||
First(&runtime).Error; err != nil {
|
||||
return nil, sourceFenceRecordError(err)
|
||||
}
|
||||
state.Project = &project
|
||||
state.Source = &source
|
||||
state.Runtime = &runtime
|
||||
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func refreshSourceCommitLease(state *sourceCommitState, snapshot *sourceExecutionSnapshot) error {
|
||||
if state == nil || state.Runtime == nil || snapshot == nil {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
now := sourceCommitNow()
|
||||
if state.Runtime.LeaseToken != snapshot.LeaseToken || state.Runtime.LeaseExpiresAt == nil ||
|
||||
!state.Runtime.LeaseExpiresAt.After(now) {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
state.Now = now
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceFenceRecordError(err error) error {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult upload.IngestResult,
|
||||
hasIngest bool,
|
||||
) (*model.PagesDeployment, bool, error) {
|
||||
var target model.PagesDeployment
|
||||
err := tx.Where(
|
||||
"project_id = ? AND source_identity = ? AND source_revision = ?",
|
||||
state.Project.ID,
|
||||
state.Source.SourceIdentity,
|
||||
revision,
|
||||
).First(&target).Error
|
||||
if err == nil {
|
||||
return &target, true, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
if !hasIngest {
|
||||
return nil, false, errSourceFinalFence
|
||||
}
|
||||
return createSourceDeploymentTx(
|
||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult,
|
||||
)
|
||||
}
|
||||
|
||||
func createSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult upload.IngestResult,
|
||||
) (*model.PagesDeployment, bool, error) {
|
||||
var maxNumber int
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", state.Project.ID).
|
||||
Select("COALESCE(MAX(deployment_number), 0)").
|
||||
Scan(&maxNumber).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
identity := state.Source.SourceIdentity
|
||||
revisionValue := revision
|
||||
target := &model.PagesDeployment{
|
||||
ProjectID: state.Project.ID,
|
||||
DeploymentNumber: maxNumber + 1,
|
||||
Checksum: revision,
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
UploadID: ingestResult.Upload.ID,
|
||||
FileCount: manifest.FileCount,
|
||||
TotalSize: manifest.TotalSize,
|
||||
CreatedBy: actor,
|
||||
SourceType: state.Source.SourceType,
|
||||
SourceIdentity: &identity,
|
||||
SourceRevision: &revisionValue,
|
||||
SourceLabel: detail.Label,
|
||||
SourceMeta: detailJSON,
|
||||
TriggerType: pagesSourceTriggerManualSync,
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(target)
|
||||
if result.Error != nil {
|
||||
return nil, false, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return reloadSourceDeploymentTx(tx, state.Project.ID, identity, revision)
|
||||
}
|
||||
if err := createSourceDeploymentFilesTx(tx, target.ID, manifest.Files); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return target, false, nil
|
||||
}
|
||||
|
||||
func reloadSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
projectID uint,
|
||||
identity string,
|
||||
revision string,
|
||||
) (*model.PagesDeployment, bool, error) {
|
||||
var target model.PagesDeployment
|
||||
err := tx.Where(
|
||||
"project_id = ? AND source_identity = ? AND source_revision = ?",
|
||||
projectID,
|
||||
identity,
|
||||
revision,
|
||||
).First(&target).Error
|
||||
return &target, true, err
|
||||
}
|
||||
|
||||
func createSourceDeploymentFilesTx(tx *gorm.DB, deploymentID uint, files []model.PagesDeploymentFile) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
for index := range files {
|
||||
files[index].DeploymentID = deploymentID
|
||||
}
|
||||
return tx.Create(&files).Error
|
||||
}
|
||||
|
||||
func lockSourceDeploymentUploadsTx(
|
||||
tx *gorm.DB,
|
||||
target *model.PagesDeployment,
|
||||
ingestResult upload.IngestResult,
|
||||
hasIngest bool,
|
||||
) error {
|
||||
uploadIDs := []uint64{target.UploadID}
|
||||
if hasIngest && ingestResult.Upload.ID != 0 && ingestResult.Upload.ID != target.UploadID {
|
||||
uploadIDs = append(uploadIDs, ingestResult.Upload.ID)
|
||||
}
|
||||
sort.Slice(uploadIDs, func(i, j int) bool { return uploadIDs[i] < uploadIDs[j] })
|
||||
var records []model.Upload
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("id IN ?", uploadIDs).
|
||||
Order("id asc").
|
||||
Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range records {
|
||||
if records[index].ID != target.UploadID {
|
||||
continue
|
||||
}
|
||||
if records[index].Status == model.UploadStatusUsed && records[index].Type == upload.ReservedPagesDeploymentType {
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
return errSourceFinalFence
|
||||
}
|
||||
|
||||
func activateSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
target *model.PagesDeployment,
|
||||
revision string,
|
||||
detailJSON string,
|
||||
) error {
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", state.Project.ID).
|
||||
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(target).Updates(map[string]any{
|
||||
"status": model.PagesDeploymentStatusActive,
|
||||
"activated_at": &state.Now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(state.Project).Update("active_deployment_id", target.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
finishedAt := sourceCommitNow()
|
||||
result := tx.Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?",
|
||||
state.Runtime.SourceID,
|
||||
state.Runtime.LeaseToken,
|
||||
finishedAt,
|
||||
).
|
||||
Updates(map[string]any{
|
||||
"last_seen_revision": revision,
|
||||
"last_seen_detail": detailJSON,
|
||||
"last_applied_revision": revision,
|
||||
"last_applied_detail": detailJSON,
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
|
||||
sourceRuntimeColumnLastError: "",
|
||||
"last_checked_at": &finishedAt,
|
||||
"last_synced_at": &finishedAt,
|
||||
"next_check_at": nil,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeRemoteSourceLabel(raw string) string {
|
||||
value := strings.ReplaceAll(strings.ToValidUTF8(raw, ""), "\\", "/")
|
||||
value = path.Base(strings.TrimSpace(value))
|
||||
if value == "." || value == "/" {
|
||||
value = ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, character := range value {
|
||||
if character >= 0x20 && character != 0x7f {
|
||||
builder.WriteRune(character)
|
||||
}
|
||||
}
|
||||
value = strings.TrimSpace(builder.String())
|
||||
if value == "" {
|
||||
value = defaultRemoteAssetLabel
|
||||
}
|
||||
if len(value) > remoteSourceMaxSafeLabelBytes {
|
||||
value = value[:remoteSourceMaxSafeLabelBytes]
|
||||
for !utf8.ValidString(value) {
|
||||
_, size := utf8.DecodeLastRuneInString(value)
|
||||
value = value[:len(value)-size]
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sourceSyncResultDetail(outcome *sourceSyncOutcome) string {
|
||||
if outcome == nil || outcome.Deployment == nil {
|
||||
return ""
|
||||
}
|
||||
payload := map[string]any{
|
||||
"deployment_id": outcome.Deployment.ID,
|
||||
"reused": outcome.Reused,
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"deployment_id":%d}`, outcome.Deployment.ID)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
Reference in New Issue
Block a user