mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
65c02ef7a5
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36}
430 lines
15 KiB
Go
430 lines
15 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package pages
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
// PagesSourceActionTask is the private Asynq task type for source actions.
|
|
PagesSourceActionTask = "openflare:pages_source_action"
|
|
// TaskTypePagesSourceAction is the internal task meta type.
|
|
TaskTypePagesSourceAction = "of_pages_source_action"
|
|
|
|
sourceActionCheck = "check"
|
|
sourceActionSync = "sync"
|
|
)
|
|
|
|
var errUnexpectedJSONTrailingValue = errors.New("unexpected trailing JSON value")
|
|
|
|
// PagesSourceActionMeta describes check/sync work for Pages deployment sources.
|
|
var PagesSourceActionMeta = task.TaskMeta{
|
|
Type: TaskTypePagesSourceAction,
|
|
AsynqTask: PagesSourceActionTask,
|
|
Name: "OpenFlare Pages 部署源操作",
|
|
Description: "检查或同步 Pages 项目部署源",
|
|
SupportsTime: false,
|
|
MaxRetry: 2,
|
|
Queue: task.QueueDefault,
|
|
Retryable: false,
|
|
}
|
|
|
|
// SourceActionPayload is the credential-free internal queue contract.
|
|
type SourceActionPayload struct {
|
|
SourceID uint `json:"source_id"`
|
|
ConfigVersion int `json:"config_version"`
|
|
Action string `json:"action"`
|
|
Actor string `json:"actor"`
|
|
TriggerType string `json:"trigger_type"`
|
|
TargetRevision string `json:"target_revision"`
|
|
ConfirmedRevision string `json:"confirmed_revision"`
|
|
}
|
|
|
|
// SourceActionHandler executes a validated source action.
|
|
type SourceActionHandler struct{}
|
|
|
|
// ValidatePayload rejects unknown keys and normalizes the internal contract.
|
|
func (h *SourceActionHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
|
var input SourceActionPayload
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil {
|
|
return nil, errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
if err := ensureJSONEOF(decoder); err != nil {
|
|
return nil, errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
input.Action = strings.TrimSpace(input.Action)
|
|
input.Actor = strings.TrimSpace(input.Actor)
|
|
input.TriggerType = strings.TrimSpace(input.TriggerType)
|
|
input.TargetRevision = strings.TrimSpace(input.TargetRevision)
|
|
input.ConfirmedRevision = strings.TrimSpace(input.ConfirmedRevision)
|
|
if input.Action == sourceActionSync && input.TriggerType == "" {
|
|
// Keep already queued Phase 2 payloads valid while making every new
|
|
// dispatch carry an explicit deployment trigger.
|
|
input.TriggerType = pagesSourceTriggerManualSync
|
|
}
|
|
if !validSourceActionPayload(input) {
|
|
return nil, errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
return json.Marshal(input)
|
|
}
|
|
|
|
func validSourceActionPayload(input SourceActionPayload) bool {
|
|
if input.SourceID == 0 || input.ConfigVersion <= 0 {
|
|
return false
|
|
}
|
|
if input.Action != sourceActionCheck && input.Action != sourceActionSync {
|
|
return false
|
|
}
|
|
if !validPagesSourceActor(input.Actor) {
|
|
return false
|
|
}
|
|
if !validOptionalSourceRevision(input.TargetRevision) ||
|
|
!validOptionalSourceRevision(input.ConfirmedRevision) {
|
|
return false
|
|
}
|
|
if input.Action == sourceActionCheck {
|
|
return input.TriggerType == "" && input.TargetRevision == "" && input.ConfirmedRevision == ""
|
|
}
|
|
return validSourceSyncPayload(input)
|
|
}
|
|
|
|
func validSourceSyncPayload(input SourceActionPayload) bool {
|
|
if !validSourceDeploymentTrigger(input.TriggerType) ||
|
|
(input.TargetRevision != "" && input.ConfirmedRevision != "") {
|
|
return false
|
|
}
|
|
switch input.TriggerType {
|
|
case pagesSourceTriggerScheduledAutoUpdate:
|
|
return input.Actor == pagesSourceCreatedBySystem &&
|
|
input.TargetRevision != "" && input.ConfirmedRevision == ""
|
|
case pagesSourceTriggerManualSync:
|
|
if input.TargetRevision != "" || !strings.HasPrefix(input.Actor, "user:") {
|
|
return false
|
|
}
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Execute validates again inside the worker and performs the source action.
|
|
func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
|
normalized, err := h.ValidatePayload(payload)
|
|
if err != nil {
|
|
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
|
}
|
|
var input SourceActionPayload
|
|
if err := json.Unmarshal(normalized, &input); err != nil {
|
|
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
|
}
|
|
|
|
source, err := repository.GetPagesProjectSourceByID(ctx, input.SourceID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
task.AppendLog(ctx, "[resolve] 来源已不存在,本次任务跳过")
|
|
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
|
}
|
|
logger.ErrorF(ctx, "[PagesSource] load source failed: source_id=%d error=%v", input.SourceID, err)
|
|
return nil, errors.New(errPagesSourceSyncFailed)
|
|
}
|
|
if source.ConfigVersion != input.ConfigVersion {
|
|
task.AppendLog(ctx, "[resolve] 来源配置已变化,本次任务跳过")
|
|
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
|
}
|
|
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
|
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
|
|
}
|
|
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
|
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
|
|
}
|
|
if (source.SourceType == PagesSourceTypeRemoteURL || input.Action == sourceActionCheck) &&
|
|
(input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
|
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
|
}
|
|
|
|
task.AppendLog(ctx, "[resolve] 正在获取来源执行权")
|
|
snapshot, outcome, err := acquireSourceLease(ctx, input.SourceID, input.ConfigVersion, input.Action)
|
|
if err != nil {
|
|
logger.ErrorF(ctx, "[PagesSource] acquire lease failed: source_id=%d error=%v", input.SourceID, err)
|
|
return nil, errors.New(errPagesSourceSyncFailed)
|
|
}
|
|
switch outcome {
|
|
case sourceLeaseBusy:
|
|
task.AppendLog(ctx, "[resolve] 已有来源任务正在执行,本次任务跳过")
|
|
return &task.TaskResult{Message: errPagesSourceActionBusy}, nil
|
|
case sourceLeaseStale:
|
|
task.AppendLog(ctx, "[resolve] 来源配置或执行权已变化,本次任务跳过")
|
|
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
|
case sourceLeaseAcquired:
|
|
// 已获取执行权,继续执行。
|
|
}
|
|
|
|
if input.Action == sourceActionCheck {
|
|
return executeGitHubCheckAction(ctx, snapshot)
|
|
}
|
|
return executeSourceSyncAction(ctx, source, snapshot, input)
|
|
}
|
|
|
|
func executeGitHubCheckAction(ctx context.Context, snapshot *sourceExecutionSnapshot) (*task.TaskResult, error) {
|
|
checkResult, checkErr := checkGitHubSource(ctx, snapshot)
|
|
if checkErr != nil {
|
|
logger.ErrorF(ctx, "[PagesSource] check failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, checkErr)
|
|
if isPermanentSourceSyncError(checkErr) || shouldSkipGitHubActionRetry(checkErr) {
|
|
return nil, task.PermanentError(checkErr.Error())
|
|
}
|
|
return nil, errors.New(errPagesSourceSyncFailed)
|
|
}
|
|
if checkResult == nil || checkResult.Stale {
|
|
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
|
}
|
|
return &task.TaskResult{Message: checkResult.Message, Detail: checkResult.Detail}, nil
|
|
}
|
|
|
|
func executeSourceSyncAction(
|
|
ctx context.Context,
|
|
source *model.PagesProjectSource,
|
|
snapshot *sourceExecutionSnapshot,
|
|
input SourceActionPayload,
|
|
) (*task.TaskResult, error) {
|
|
var result *sourceSyncOutcome
|
|
var err error
|
|
if source.SourceType == PagesSourceTypeGitHubRelease {
|
|
result, err = syncGitHubSourceWithTrigger(
|
|
ctx, snapshot, input.Actor, input.TargetRevision, input.ConfirmedRevision, input.TriggerType,
|
|
)
|
|
} else {
|
|
result, err = syncRemoteSourceWithTrigger(ctx, snapshot, input.Actor, input.TriggerType)
|
|
}
|
|
if err != nil {
|
|
logger.ErrorF(ctx, "[PagesSource] sync failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, err)
|
|
if isPermanentSourceSyncError(err) || shouldSkipGitHubActionRetry(err) {
|
|
return nil, task.PermanentError(errPagesSourceSyncFailed)
|
|
}
|
|
return nil, errors.New(errPagesSourceSyncFailed)
|
|
}
|
|
if result == nil || result.Stale {
|
|
task.AppendLog(ctx, "[activate] 来源配置或执行权已变化,本次任务未切换部署")
|
|
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
|
}
|
|
message := "Pages 部署源同步并发布成功"
|
|
if result.Reused {
|
|
message = "Pages 部署源内容未变化,已重新激活现有部署"
|
|
}
|
|
return &task.TaskResult{Message: message, Detail: sourceSyncResultDetail(result)}, nil
|
|
}
|
|
|
|
func ensureJSONEOF(decoder *json.Decoder) error {
|
|
var trailing any
|
|
err := decoder.Decode(&trailing)
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
if err == nil {
|
|
return errUnexpectedJSONTrailingValue
|
|
}
|
|
return err
|
|
}
|
|
|
|
func validPagesSourceActor(actor string) bool {
|
|
if actor == pagesSourceCreatedBySystem {
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(actor, "user:") {
|
|
return false
|
|
}
|
|
id, err := strconv.ParseUint(strings.TrimPrefix(actor, "user:"), 10, 64)
|
|
return err == nil && id > 0
|
|
}
|
|
|
|
func validOptionalSourceRevision(value string) bool {
|
|
if value == "" {
|
|
return true
|
|
}
|
|
if len(value) != sourceRevisionHexLength {
|
|
return false
|
|
}
|
|
decoded, err := hex.DecodeString(value)
|
|
return err == nil && len(decoded) == 32
|
|
}
|
|
|
|
func isPermanentSourceSyncError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := err.Error()
|
|
return strings.Contains(message, errPagesPackageUnsupported) ||
|
|
strings.Contains(message, errPagesPackageURLTooLarge) ||
|
|
strings.Contains(message, errPagesPackageInvalid) ||
|
|
strings.Contains(message, errPagesPackageEmpty) ||
|
|
strings.Contains(message, errPagesPackageExtractedTooLarge) ||
|
|
strings.Contains(message, errPagesPackageFileTooLarge) ||
|
|
strings.Contains(message, errPagesEntryFileMissing) ||
|
|
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
|
|
strings.Contains(message, errPagesSourceReleaseNotFound) ||
|
|
strings.Contains(message, errPagesSourceDigestInvalid) ||
|
|
strings.Contains(message, errPagesSourceDigestMismatch) ||
|
|
strings.Contains(message, errPagesSourceConfirmationNeeded) ||
|
|
strings.Contains(message, errPagesSourceConfirmationStale)
|
|
}
|
|
|
|
// DispatchSourceAction performs API preflight and enqueues a credential-free action.
|
|
func DispatchSourceAction(
|
|
ctx context.Context,
|
|
projectID uint,
|
|
action string,
|
|
actor string,
|
|
confirmedRevision string,
|
|
) (*SourceActionReceipt, error) {
|
|
return dispatchSourceActionByProject(ctx, projectID, action, actor, "", confirmedRevision)
|
|
}
|
|
|
|
func dispatchSourceActionByProject(
|
|
ctx context.Context,
|
|
projectID uint,
|
|
action string,
|
|
actor string,
|
|
targetRevision string,
|
|
confirmedRevision string,
|
|
) (*SourceActionReceipt, error) {
|
|
action = strings.TrimSpace(action)
|
|
targetRevision = strings.TrimSpace(targetRevision)
|
|
confirmedRevision = strings.TrimSpace(confirmedRevision)
|
|
if action != sourceActionCheck && action != sourceActionSync {
|
|
return nil, errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
if !validPagesSourceActor(actor) {
|
|
return nil, errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
|
|
source, err := repository.GetPagesProjectSourceByProjectID(ctx, projectID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New(errPagesSourceNotFound)
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := validateSourceActionPreflight(ctx, source, action, targetRevision, confirmedRevision); err != nil {
|
|
return nil, err
|
|
}
|
|
busy, err := sourceLeaseIsBusy(ctx, source.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if busy {
|
|
return nil, errors.New(errPagesSourceActionBusy)
|
|
}
|
|
return dispatchSourceActionSnapshot(ctx, *source, action, actor, targetRevision, confirmedRevision, "manual")
|
|
}
|
|
|
|
func validateSourceActionPreflight(
|
|
ctx context.Context,
|
|
source *model.PagesProjectSource,
|
|
action string,
|
|
targetRevision string,
|
|
confirmedRevision string,
|
|
) error {
|
|
if source == nil {
|
|
return errors.New(errPagesSourceNotFound)
|
|
}
|
|
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
|
return errors.New(errPagesSourceTypeUnsupported)
|
|
}
|
|
if action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
|
return errors.New(errPagesSourceCheckUnsupported)
|
|
}
|
|
if source.SourceType == PagesSourceTypeRemoteURL && (targetRevision != "" || confirmedRevision != "") {
|
|
return errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
if action == sourceActionCheck && (targetRevision != "" || confirmedRevision != "") {
|
|
return errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
if source.SourceType == PagesSourceTypeGitHubRelease && action == sourceActionSync {
|
|
if err := preflightGitHubSyncConfirmation(ctx, source.ID, confirmedRevision); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func dispatchSourceActionSnapshot(
|
|
ctx context.Context,
|
|
source model.PagesProjectSource,
|
|
action string,
|
|
actor string,
|
|
targetRevision string,
|
|
confirmedRevision string,
|
|
triggeredBy string,
|
|
) (*SourceActionReceipt, error) {
|
|
triggerType := ""
|
|
if action == sourceActionSync {
|
|
triggerType = pagesSourceTriggerManualSync
|
|
}
|
|
return dispatchSourceActionSnapshotWithTrigger(
|
|
ctx, source, action, actor, triggerType, targetRevision, confirmedRevision, triggeredBy,
|
|
)
|
|
}
|
|
|
|
func dispatchSourceActionSnapshotWithTrigger(
|
|
ctx context.Context,
|
|
source model.PagesProjectSource,
|
|
action string,
|
|
actor string,
|
|
triggerType string,
|
|
targetRevision string,
|
|
confirmedRevision string,
|
|
triggeredBy string,
|
|
) (*SourceActionReceipt, error) {
|
|
if task.AsynqClient == nil {
|
|
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
|
}
|
|
handler := &SourceActionHandler{}
|
|
rawPayload, err := json.Marshal(SourceActionPayload{
|
|
SourceID: source.ID,
|
|
ConfigVersion: source.ConfigVersion,
|
|
Action: action,
|
|
Actor: actor,
|
|
TriggerType: triggerType,
|
|
TargetRevision: targetRevision,
|
|
ConfirmedRevision: confirmedRevision,
|
|
})
|
|
if err != nil {
|
|
return nil, errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
payload, err := handler.ValidatePayload(rawPayload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, triggeredBy)
|
|
if err != nil {
|
|
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", source.ProjectID, source.ID, action, err)
|
|
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
|
}
|
|
execution, err := repository.GetTaskExecutionByTaskID(ctx, taskID)
|
|
if err != nil {
|
|
logger.ErrorF(ctx, "[PagesSource] load dispatched execution failed: source_id=%d task_id=%s error=%v", source.ID, taskID, err)
|
|
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
|
}
|
|
return &SourceActionReceipt{
|
|
TaskID: taskID,
|
|
ExecutionID: strconv.FormatUint(execution.ID, 10),
|
|
Action: action,
|
|
}, nil
|
|
}
|