mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +08:00
feat(pages): 支持 GitHub Release 部署源
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
@@ -5,51 +5,63 @@
|
||||
package pages
|
||||
|
||||
const (
|
||||
errPagesProjectNotFound = "pages 项目不存在"
|
||||
errPagesSlugExists = "pages 项目标识已存在"
|
||||
errPagesNameRequired = "pages 项目名称不能为空"
|
||||
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
|
||||
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
|
||||
errPagesDeploymentNotFound = "pages 部署不存在"
|
||||
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
||||
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
||||
errPagesPackageMissing = "缺少 Pages 部署包"
|
||||
errPagesPackageURLRequired = "请填写部署包下载链接"
|
||||
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
|
||||
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
|
||||
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
|
||||
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
|
||||
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
|
||||
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
|
||||
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
|
||||
errPagesPackageEmpty = "pages 部署包不能为空"
|
||||
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
|
||||
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
|
||||
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
|
||||
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
|
||||
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesPackagePathEmpty = "pages 部署包路径为空"
|
||||
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
|
||||
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
||||
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
||||
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
||||
errPagesActorMissing = "无法识别当前用户"
|
||||
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
||||
errPagesSourceNotFound = "pages 部署源不存在"
|
||||
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
|
||||
errPagesSourceTypeUnsupported = "当前阶段仅支持远程地址部署源"
|
||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
|
||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
|
||||
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
|
||||
errPagesSourceActionBusy = "pages 部署源任务正在执行"
|
||||
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
|
||||
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
|
||||
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
|
||||
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
||||
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
||||
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
||||
errPagesProjectNotFound = "pages 项目不存在"
|
||||
errPagesSlugExists = "pages 项目标识已存在"
|
||||
errPagesNameRequired = "pages 项目名称不能为空"
|
||||
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
|
||||
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
|
||||
errPagesDeploymentNotFound = "pages 部署不存在"
|
||||
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
||||
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
||||
errPagesPackageMissing = "缺少 Pages 部署包"
|
||||
errPagesPackageURLRequired = "请填写部署包下载链接"
|
||||
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
|
||||
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
|
||||
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
|
||||
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
|
||||
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
|
||||
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
|
||||
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
|
||||
errPagesPackageEmpty = "pages 部署包不能为空"
|
||||
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
|
||||
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
|
||||
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
|
||||
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
|
||||
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesPackagePathEmpty = "pages 部署包路径为空"
|
||||
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
|
||||
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
||||
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
||||
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
||||
errPagesActorMissing = "无法识别当前用户"
|
||||
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
||||
errPagesSourceNotFound = "pages 部署源不存在"
|
||||
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
|
||||
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
|
||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
|
||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
|
||||
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
|
||||
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
|
||||
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
|
||||
errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名"
|
||||
errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间"
|
||||
errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭"
|
||||
errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源"
|
||||
errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效"
|
||||
errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败"
|
||||
errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本"
|
||||
errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认"
|
||||
errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查"
|
||||
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
|
||||
errPagesSourceActionBusy = "pages 部署源任务正在执行"
|
||||
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
|
||||
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
|
||||
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
|
||||
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
||||
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
||||
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
githubReleaseSelectorLatest = "latest"
|
||||
githubReleaseSelectorTag = "tag"
|
||||
githubSourceIdentityDomain = "openflare:pages:github-release:v2"
|
||||
initialCheckRetryDelay = 5 * time.Minute
|
||||
githubRepositoryPathParts = 2
|
||||
githubCheckJitterRange = 301
|
||||
githubCheckJitterCenter = 150
|
||||
)
|
||||
|
||||
var (
|
||||
githubOwnerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
|
||||
githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type githubSourceConfig struct {
|
||||
Repository string
|
||||
Selector string
|
||||
Tag string
|
||||
AssetName string
|
||||
CheckInterval int
|
||||
SourceIdentity string
|
||||
}
|
||||
|
||||
func validateGitHubSourceInput(input SourceUpdateInput) error {
|
||||
if strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if input.RemoteURLSet || strings.TrimSpace(input.RemoteURL) != "" ||
|
||||
strings.TrimSpace(input.RemoteNetworkPolicy) != "" {
|
||||
return errors.New(errPagesSourceGitHubFields)
|
||||
}
|
||||
if input.AutoUpdateEnabled {
|
||||
return errors.New(errPagesSourceAutoNotAvailable)
|
||||
}
|
||||
if _, err := normalizeGitHubRepositoryURL(input.RepositoryURL); err != nil {
|
||||
return err
|
||||
}
|
||||
selector := strings.TrimSpace(input.ReleaseSelector)
|
||||
if selector == "" {
|
||||
selector = githubReleaseSelectorLatest
|
||||
}
|
||||
assetName := input.AssetName
|
||||
if assetName == "" {
|
||||
assetName = defaultGitHubAssetName
|
||||
}
|
||||
if !validGitHubAssetName(assetName) {
|
||||
return errors.New(errPagesSourceAssetNameInvalid)
|
||||
}
|
||||
switch selector {
|
||||
case githubReleaseSelectorLatest:
|
||||
if input.ReleaseTag != "" {
|
||||
return errors.New(errPagesSourceSelectorInvalid)
|
||||
}
|
||||
interval := input.CheckIntervalMinutes
|
||||
if interval != 0 && (interval < minimumCheckInterval || interval > maximumCheckInterval) {
|
||||
return errors.New(errPagesSourceCheckInterval)
|
||||
}
|
||||
case githubReleaseSelectorTag:
|
||||
if !validGitHubReleaseTagConfig(input.ReleaseTag) || input.CheckIntervalMinutes != 0 {
|
||||
return errors.New(errPagesSourceSelectorInvalid)
|
||||
}
|
||||
default:
|
||||
return errors.New(errPagesSourceSelectorInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildGitHubSourceConfig(input SourceUpdateInput) (githubSourceConfig, error) {
|
||||
repository, err := normalizeGitHubRepositoryURL(input.RepositoryURL)
|
||||
if err != nil {
|
||||
return githubSourceConfig{}, err
|
||||
}
|
||||
selector := strings.TrimSpace(input.ReleaseSelector)
|
||||
if selector == "" {
|
||||
selector = githubReleaseSelectorLatest
|
||||
}
|
||||
tag := input.ReleaseTag
|
||||
assetName := input.AssetName
|
||||
if assetName == "" {
|
||||
assetName = defaultGitHubAssetName
|
||||
}
|
||||
interval := input.CheckIntervalMinutes
|
||||
if selector == githubReleaseSelectorLatest && interval == 0 {
|
||||
interval = defaultCheckInterval
|
||||
}
|
||||
return githubSourceConfig{
|
||||
Repository: repository,
|
||||
Selector: selector,
|
||||
Tag: tag,
|
||||
AssetName: assetName,
|
||||
CheckInterval: interval,
|
||||
SourceIdentity: buildGitHubSourceIdentity(repository, selector, tag, assetName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildGitHubSourceIdentity(repository, selector, tag, assetName string) string {
|
||||
fields := [...]string{repository, selector, tag, assetName}
|
||||
encoded := make([]byte, 0, len(githubSourceIdentityDomain)+len(fields)*8+
|
||||
len(repository)+len(selector)+len(tag)+len(assetName))
|
||||
encoded = append(encoded, githubSourceIdentityDomain...)
|
||||
var fieldLength [8]byte
|
||||
for _, field := range fields {
|
||||
// Go strings hold the validated UTF-8 bytes used by GitHub. Prefixing each
|
||||
// field with its byte length prevents delimiter characters from creating
|
||||
// ambiguous identities across field boundaries.
|
||||
binary.BigEndian.PutUint64(fieldLength[:], uint64(len(field)))
|
||||
encoded = append(encoded, fieldLength[:]...)
|
||||
encoded = append(encoded, field...)
|
||||
}
|
||||
identityHash := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(identityHash[:])
|
||||
}
|
||||
|
||||
func normalizeGitHubRepositoryURL(raw string) (string, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, "github.com") ||
|
||||
parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" ||
|
||||
strings.Contains(raw, "#") ||
|
||||
parsed.EscapedPath() != parsed.Path || !strings.HasPrefix(parsed.Path, "/") ||
|
||||
strings.HasPrefix(parsed.Path, "//") || strings.HasSuffix(parsed.Path, "/") {
|
||||
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
|
||||
if len(parts) != githubRepositoryPathParts {
|
||||
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||
}
|
||||
owner := parts[0]
|
||||
repository := parts[1]
|
||||
repository = strings.TrimSuffix(repository, ".git")
|
||||
if !githubOwnerPattern.MatchString(owner) || !githubRepoPattern.MatchString(repository) ||
|
||||
len(repository) > 100 || repository == "." || repository == ".." {
|
||||
return "", errors.New(errPagesSourceRepositoryInvalid)
|
||||
}
|
||||
return owner + "/" + repository, nil
|
||||
}
|
||||
|
||||
func validGitHubReleaseTagConfig(value string) bool {
|
||||
if !validGitHubReleaseDisplayTag(value) ||
|
||||
strings.ContainsAny(value, " ~^:?*[\\") || strings.HasPrefix(value, "/") ||
|
||||
strings.HasSuffix(value, "/") || strings.HasSuffix(value, ".") ||
|
||||
strings.Contains(value, "//") || strings.Contains(value, "..") || strings.Contains(value, "@{") {
|
||||
return false
|
||||
}
|
||||
for component := range strings.SplitSeq(value, "/") {
|
||||
if strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validGitHubReleaseDisplayTag(value string) bool {
|
||||
if value == "" || len(value) > 255 || !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if unsafeGitHubInputRune(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validGitHubAssetName(value string) bool {
|
||||
if value == "" || len(value) > 255 || !utf8.ValidString(value) ||
|
||||
path.Base(value) != value || strings.Contains(value, "\\") ||
|
||||
value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if unsafeGitHubInputRune(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func unsafeGitHubInputRune(character rune) bool {
|
||||
return unicode.IsControl(character) || character == '\u2028' || character == '\u2029' ||
|
||||
character == '\u061c' || character == '\u200e' || character == '\u200f' ||
|
||||
(character >= '\u202a' && character <= '\u202e') ||
|
||||
(character >= '\u2066' && character <= '\u2069')
|
||||
}
|
||||
|
||||
func updateGitHubSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
config, err := buildGitHubSourceConfig(input)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hasExisting {
|
||||
return true, createGitHubSourceTx(tx, projectID, config)
|
||||
}
|
||||
if !githubSourceConfigChanged(existing, config) {
|
||||
return false, nil
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("source_id = ?", existing.ID).First(&runtime).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
identityChanged := existing.SourceIdentity != config.SourceIdentity
|
||||
if err := tx.Model(existing).Updates(githubSourceUpdates(config, existing.ConfigVersion+1)).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := resetRuntimeAfterGitHubUpdate(tx, &runtime, config, identityChanged); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func createGitHubSourceTx(tx *gorm.DB, projectID uint, config githubSourceConfig) error {
|
||||
source := &model.PagesProjectSource{
|
||||
ProjectID: projectID,
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
GitHubRepository: config.Repository,
|
||||
ReleaseSelector: config.Selector,
|
||||
ReleaseTag: config.Tag,
|
||||
AssetName: config.AssetName,
|
||||
AutoUpdateEnabled: false,
|
||||
CheckIntervalMinutes: config.CheckInterval,
|
||||
ConfigVersion: 1,
|
||||
SourceIdentity: config.SourceIdentity,
|
||||
}
|
||||
if err := tx.Create(source).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
runtime := &model.PagesProjectSourceRuntime{SourceID: source.ID, SyncStatus: pagesSourceStatusIdle}
|
||||
if config.Selector == githubReleaseSelectorLatest {
|
||||
next := nextGitHubCheckAt(time.Now(), source.ID, config.CheckInterval)
|
||||
runtime.NextCheckAt = &next
|
||||
}
|
||||
return tx.Create(runtime).Error
|
||||
}
|
||||
|
||||
func githubSourceUpdates(config githubSourceConfig, version int) map[string]any {
|
||||
return map[string]any{
|
||||
"source_type": PagesSourceTypeGitHubRelease,
|
||||
"remote_url": "",
|
||||
"remote_network_policy": "",
|
||||
"github_repository": config.Repository,
|
||||
"release_selector": config.Selector,
|
||||
"release_tag": config.Tag,
|
||||
"asset_name": config.AssetName,
|
||||
sourceColumnAutoUpdateEnabled: false,
|
||||
"check_interval_minutes": config.CheckInterval,
|
||||
sourceColumnConfigVersion: version,
|
||||
"source_identity": config.SourceIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
func githubSourceConfigChanged(existing *model.PagesProjectSource, config githubSourceConfig) bool {
|
||||
return existing.SourceType != PagesSourceTypeGitHubRelease || existing.RemoteURL != "" ||
|
||||
existing.RemoteNetworkPolicy != "" || existing.GitHubRepository != config.Repository ||
|
||||
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
|
||||
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled ||
|
||||
existing.CheckIntervalMinutes != config.CheckInterval
|
||||
}
|
||||
|
||||
func resetRuntimeAfterGitHubUpdate(
|
||||
tx *gorm.DB,
|
||||
runtime *model.PagesProjectSourceRuntime,
|
||||
config githubSourceConfig,
|
||||
identityChanged bool,
|
||||
) error {
|
||||
if err := resetRuntimeAfterSourceUpdate(tx, runtime, identityChanged); err != nil {
|
||||
return err
|
||||
}
|
||||
var nextCheckAt any
|
||||
if config.Selector == githubReleaseSelectorLatest {
|
||||
next := nextGitHubCheckAt(time.Now(), runtime.SourceID, config.CheckInterval)
|
||||
nextCheckAt = &next
|
||||
}
|
||||
return tx.Model(runtime).Update("next_check_at", nextCheckAt).Error
|
||||
}
|
||||
|
||||
func nextGitHubCheckAt(now time.Time, sourceID uint, intervalMinutes int) time.Time {
|
||||
// A stable, bounded offset avoids a thundering herd without persisting
|
||||
// another scheduling field. Scanner Phase 3 reuses this calculation.
|
||||
jitterSeconds := int64(sourceID%githubCheckJitterRange) - githubCheckJitterCenter
|
||||
return now.Add(time.Duration(intervalMinutes)*time.Minute + time.Duration(jitterSeconds)*time.Second)
|
||||
}
|
||||
|
||||
func markInitialCheckDispatchFailed(ctx context.Context, sourceID uint, configVersion int) {
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||
sourceRuntimeColumnLastError: errPagesSourceInitialCheckWarning,
|
||||
}
|
||||
var source model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("id = ? AND config_version = ?", sourceID, configVersion).First(&source).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.ErrorF(ctx, "[PagesSource] load initial check source snapshot failed: source_id=%d error=%v", sourceID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if source.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
next := time.Now().Add(initialCheckRetryDelay)
|
||||
updates["next_check_at"] = &next
|
||||
}
|
||||
now := time.Now()
|
||||
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, configVersion).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] mark initial check dispatch failure: source_id=%d error=%v", sourceID, result.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/task"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const githubSourceDetailProvider = "github"
|
||||
|
||||
var githubDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
type githubSourceProviderDomainError struct {
|
||||
message string
|
||||
permanent bool
|
||||
retryAt *time.Time
|
||||
}
|
||||
|
||||
func (domainError *githubSourceProviderDomainError) Error() string {
|
||||
return domainError.message
|
||||
}
|
||||
|
||||
type githubReleaseAPI interface {
|
||||
Resolve(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
|
||||
Download(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
|
||||
}
|
||||
|
||||
var newGitHubReleaseClient = func() githubReleaseAPI {
|
||||
return githubrelease.NewClient()
|
||||
}
|
||||
|
||||
type githubSourceTarget struct {
|
||||
Revision string
|
||||
Detail sourceDetail
|
||||
DetailJSON string
|
||||
Release githubrelease.Release
|
||||
Asset githubrelease.Asset
|
||||
RetryAt *time.Time
|
||||
}
|
||||
|
||||
type githubCheckTaskResult struct {
|
||||
Message string
|
||||
Detail string
|
||||
Stale bool
|
||||
}
|
||||
|
||||
type preparedGitHubSource struct {
|
||||
target *githubSourceTarget
|
||||
download *githubrelease.DownloadResult
|
||||
format pagesarchive.Format
|
||||
manifest *deploymentManifest
|
||||
ingestState *sourceIngestState
|
||||
limits pagesLimits
|
||||
}
|
||||
|
||||
func checkGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
) (*githubCheckTaskResult, error) {
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
client := newGitHubReleaseClient()
|
||||
result, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
|
||||
Tag: snapshot.ReleaseTag,
|
||||
AssetName: snapshot.AssetName,
|
||||
ETag: snapshot.ETag,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
|
||||
retryAt, _ := githubrelease.RetryAt(err)
|
||||
domainErr := githubSourceDomainError(err)
|
||||
if failErr := failGitHubCheckLease(ctx, snapshot, domainErr.Error(), retryAt); failErr != nil {
|
||||
if errors.Is(failErr, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, failErr
|
||||
}
|
||||
return nil, domainErr
|
||||
}
|
||||
if result.NotModified {
|
||||
if err := finishGitHubCheckNotModified(ctx, snapshot, result); err != nil {
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &githubCheckTaskResult{Message: "GitHub Release 检查完成,内容未变化"}, nil
|
||||
}
|
||||
target, err := buildGitHubSourceTarget(result.Release, result.Asset, result.RetryAt)
|
||||
if err != nil {
|
||||
retryAt := time.Time{}
|
||||
if result.RetryAt != nil {
|
||||
retryAt = result.RetryAt.UTC()
|
||||
}
|
||||
if failErr := failGitHubCheckLease(ctx, snapshot, err.Error(), retryAt); failErr != nil {
|
||||
if errors.Is(failErr, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, failErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
status, err := finishGitHubCheckTarget(ctx, snapshot, result, target)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &githubCheckTaskResult{Message: errPagesSourceActionStale, Stale: true}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status})
|
||||
message := "GitHub Release 检查完成"
|
||||
switch status {
|
||||
case pagesSourceStatusUpdateAvailable:
|
||||
message = "发现新的 GitHub Release 部署包"
|
||||
case pagesSourceStatusAttention:
|
||||
message = "检测到同一 Release 的资源被替换,需要确认"
|
||||
}
|
||||
return &githubCheckTaskResult{Message: message, Detail: string(detail)}, nil
|
||||
}
|
||||
|
||||
func buildGitHubSourceTarget(
|
||||
release githubrelease.Release,
|
||||
asset githubrelease.Asset,
|
||||
retryAt *time.Time,
|
||||
) (*githubSourceTarget, error) {
|
||||
digest := strings.ToLower(strings.TrimSpace(asset.Digest))
|
||||
if digest != "" && !githubDigestPattern.MatchString(digest) {
|
||||
return nil, errors.New(errPagesSourceDigestInvalid)
|
||||
}
|
||||
if strings.TrimSpace(release.ID) == "" || strings.TrimSpace(asset.ID) == "" ||
|
||||
!validGitHubReleaseDisplayTag(release.Tag) || !validGitHubAssetName(asset.Name) ||
|
||||
asset.State != "uploaded" || asset.UpdatedAt.IsZero() {
|
||||
return nil, errors.New(errPagesSourceReleaseNotFound)
|
||||
}
|
||||
updatedAt := asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
rawRevision := "github:" + release.ID + ":" + asset.ID + ":" + updatedAt + ":" + digest
|
||||
sum := sha256.Sum256([]byte(rawRevision))
|
||||
detail := sourceDetail{
|
||||
Provider: githubSourceDetailProvider,
|
||||
Tag: release.Tag,
|
||||
AssetName: asset.Name,
|
||||
ReleaseID: release.ID,
|
||||
AssetID: asset.ID,
|
||||
AssetUpdatedAt: updatedAt,
|
||||
Digest: digest,
|
||||
}
|
||||
detailJSON, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
return &githubSourceTarget{
|
||||
Revision: hex.EncodeToString(sum[:]),
|
||||
Detail: detail,
|
||||
DetailJSON: string(detailJSON),
|
||||
Release: release,
|
||||
Asset: asset,
|
||||
RetryAt: retryAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func finishGitHubCheckNotModified(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
result githubrelease.ResolveResult,
|
||||
) error {
|
||||
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
|
||||
updates["etag"] = result.ETag
|
||||
updates[sourceRuntimeColumnSyncStatus] = normalizedSourceRuntimeStatus(runtime)
|
||||
return tx.Model(runtime).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
func finishGitHubCheckTarget(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
result githubrelease.ResolveResult,
|
||||
target *githubSourceTarget,
|
||||
) (string, error) {
|
||||
status := pagesSourceStatusIdle
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status = targetRuntimeStatus(target, runtime.LastAppliedRevision, runtime.LastAppliedDetail)
|
||||
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
|
||||
updates["etag"] = result.ETag
|
||||
updates["last_seen_revision"] = target.Revision
|
||||
updates["last_seen_detail"] = target.DetailJSON
|
||||
updates[sourceRuntimeColumnSyncStatus] = status
|
||||
return tx.Model(runtime).Updates(updates).Error
|
||||
})
|
||||
return status, err
|
||||
}
|
||||
|
||||
func githubCheckTerminalUpdates(
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
now time.Time,
|
||||
retryAt *time.Time,
|
||||
) map[string]any {
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnLastError: "",
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
updates["next_check_at"] = nextCheckAfterGitHubResponse(snapshot, now, retryAt)
|
||||
return updates
|
||||
}
|
||||
|
||||
func nextCheckAfterGitHubResponse(
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
now time.Time,
|
||||
retryAt *time.Time,
|
||||
) any {
|
||||
if snapshot.ReleaseSelector != githubReleaseSelectorLatest {
|
||||
return nil
|
||||
}
|
||||
next := nextGitHubCheckAt(now, snapshot.SourceID, snapshot.CheckIntervalMinutes)
|
||||
if retryAt != nil && retryAt.After(next) {
|
||||
next = retryAt.UTC()
|
||||
}
|
||||
return &next
|
||||
}
|
||||
|
||||
func lockOwnedSourceRuntime(
|
||||
tx *gorm.DB,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
) (*model.PagesProjectSourceRuntime, time.Time, error) {
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("source_id = ?", snapshot.SourceID).First(&runtime).Error; err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
if runtime.LeaseToken != snapshot.LeaseToken || runtime.LeaseExpiresAt == nil ||
|
||||
!runtime.LeaseExpiresAt.After(now) {
|
||||
return nil, time.Time{}, errSourceFinalFence
|
||||
}
|
||||
return &runtime, now, nil
|
||||
}
|
||||
|
||||
func failGitHubCheckLease(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
message string,
|
||||
retryAt time.Time,
|
||||
) error {
|
||||
now := time.Now()
|
||||
next := now.Add(initialCheckRetryDelay)
|
||||
if retryAt.After(next) {
|
||||
next = retryAt.UTC()
|
||||
}
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||
sourceRuntimeColumnLastError: safeSourceRuntimeError(message),
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
if snapshot.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
updates["next_check_at"] = &next
|
||||
} else {
|
||||
updates["next_check_at"] = nil
|
||||
}
|
||||
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func targetRuntimeStatus(
|
||||
target *githubSourceTarget,
|
||||
appliedRevision string,
|
||||
appliedDetail string,
|
||||
) string {
|
||||
if target == nil || target.Revision == appliedRevision {
|
||||
return pagesSourceStatusIdle
|
||||
}
|
||||
applied := sourceDetail{}
|
||||
if unmarshalSourceDetail(appliedDetail, &applied) == nil && target.Detail.ReleaseID != "" &&
|
||||
target.Detail.ReleaseID == applied.ReleaseID {
|
||||
return pagesSourceStatusAttention
|
||||
}
|
||||
return pagesSourceStatusUpdateAvailable
|
||||
}
|
||||
|
||||
func preflightGitHubSyncConfirmation(ctx context.Context, sourceID uint, confirmedRevision string) error {
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
replacement := sourceHasSameReleaseReplacement(&runtime)
|
||||
if replacement && confirmedRevision == "" {
|
||||
return errors.New(errPagesSourceConfirmationNeeded)
|
||||
}
|
||||
if confirmedRevision != "" && (!replacement || confirmedRevision != runtime.LastSeenRevision) {
|
||||
return errors.New(errPagesSourceConfirmationStale)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (outcome *sourceSyncOutcome, resultErr error) {
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease || !validPagesSourceActor(actor) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
defer func() {
|
||||
resultErr = finalizeGitHubSyncFailure(ctx, snapshot, resultErr)
|
||||
}()
|
||||
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
|
||||
ctx, snapshot, pagesSourceSyncLeaseDuration, pagesSourceHeartbeatInterval,
|
||||
)
|
||||
if err != nil {
|
||||
return sourceHeartbeatOutcome(err)
|
||||
}
|
||||
defer func() { _ = heartbeat.stop() }()
|
||||
|
||||
client := newGitHubReleaseClient()
|
||||
target, guardedOutcome, err := resolveAndGuardGitHubSync(
|
||||
workCtx, client, snapshot, targetRevision, confirmedRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if guardedOutcome != nil {
|
||||
return guardedOutcome, nil
|
||||
}
|
||||
prepared, err := prepareGitHubSyncPackage(workCtx, client, snapshot, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := prepared.download.Cleanup(); cleanupErr != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||
}
|
||||
}()
|
||||
defer compensateSourceIngest(ctx, snapshot, prepared.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
|
||||
}
|
||||
return activatePreparedGitHubSource(ctx, snapshot, actor, prepared)
|
||||
}
|
||||
|
||||
func finalizeGitHubSyncFailure(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
resultErr error,
|
||||
) error {
|
||||
if resultErr == nil {
|
||||
return nil
|
||||
}
|
||||
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||
defer cancel()
|
||||
finalizerErr := persistGitHubSyncFailure(cleanupCtx, snapshot, resultErr)
|
||||
if finalizerErr == nil {
|
||||
return resultErr
|
||||
}
|
||||
logger.WarnF(
|
||||
cleanupCtx,
|
||||
"[PagesSource] finalize GitHub sync failure failed: source_id=%d source_error=%s error=%v",
|
||||
snapshot.SourceID, safeGitHubSourceError(resultErr), finalizerErr,
|
||||
)
|
||||
// final fence 丢失表示已有新任务接管 runtime,不应覆盖;数据库
|
||||
// finalizer 失败则保持可重试,避免继承永久错误或 provider deadline 分类。
|
||||
if errors.Is(finalizerErr, errSourceFinalFence) {
|
||||
return resultErr
|
||||
}
|
||||
return errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
|
||||
func persistGitHubSyncFailure(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
resultErr error,
|
||||
) error {
|
||||
var domainError *githubSourceProviderDomainError
|
||||
if errors.As(resultErr, &domainError) && domainError.retryAt != nil {
|
||||
return failGitHubCheckLease(ctx, snapshot, domainError.message, *domainError.retryAt)
|
||||
}
|
||||
return failSourceLease(ctx, snapshot, safeGitHubSourceError(resultErr))
|
||||
}
|
||||
|
||||
func activatePreparedGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
prepared *preparedGitHubSource,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
|
||||
deployment, reused, referenced, err := commitSourceDeployment(
|
||||
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
|
||||
prepared.target.Detail, prepared.target.DetailJSON, actor, prepared.manifest,
|
||||
prepared.ingestState.Result, prepared.ingestState.HasIngest, prepared.target.RetryAt,
|
||||
)
|
||||
prepared.ingestState.Referenced = referenced
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prepared.ingestState.Referenced = prepared.ingestState.HasIngest && deployment.UploadID == prepared.ingestState.Result.Upload.ID
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, prepared.limits.HistoryCount, 0); pruneErr != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] strict prune failed after GitHub sync: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, pruneErr)
|
||||
}
|
||||
view := buildDeploymentView(deployment)
|
||||
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
|
||||
}
|
||||
|
||||
func resolveAndGuardGitHubSync(
|
||||
ctx context.Context,
|
||||
client githubReleaseAPI,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*githubSourceTarget, *sourceSyncOutcome, error) {
|
||||
task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
|
||||
Tag: snapshot.ReleaseTag,
|
||||
AssetName: snapshot.AssetName,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] GitHub resolve failed: source_id=%d repo=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, err)
|
||||
return nil, nil, githubSourceDomainError(err)
|
||||
}
|
||||
if resolved.NotModified {
|
||||
return nil, nil, errors.New(errPagesSourceReleaseNotFound)
|
||||
}
|
||||
target, err := buildGitHubSourceTarget(resolved.Release, resolved.Asset, resolved.RetryAt)
|
||||
if err != nil {
|
||||
return nil, nil, &githubSourceProviderDomainError{
|
||||
message: safeGitHubSourceError(err),
|
||||
permanent: isPermanentSourceSyncError(err),
|
||||
retryAt: resolved.RetryAt,
|
||||
}
|
||||
}
|
||||
guardedOutcome, err := guardGitHubSyncTarget(ctx, snapshot, target, targetRevision, confirmedRevision)
|
||||
return target, guardedOutcome, err
|
||||
}
|
||||
|
||||
func guardGitHubSyncTarget(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
status := targetRuntimeStatus(target, snapshot.LastAppliedRevision, snapshot.LastAppliedDetail)
|
||||
if targetRevision != "" && targetRevision != target.Revision {
|
||||
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, "", true, true)
|
||||
}
|
||||
if confirmedRevision != "" && (confirmedRevision != snapshot.LastSeenRevision || confirmedRevision != target.Revision) {
|
||||
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationStale, false, false)
|
||||
}
|
||||
if status == pagesSourceStatusAttention && confirmedRevision != target.Revision {
|
||||
return releaseGuardedGitHubTarget(ctx, snapshot, target, status, errPagesSourceConfirmationNeeded, false, false)
|
||||
}
|
||||
if confirmedRevision != "" && status != pagesSourceStatusAttention {
|
||||
return nil, errors.New(errPagesSourceConfirmationStale)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func releaseGuardedGitHubTarget(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
status string,
|
||||
lastError string,
|
||||
expedite bool,
|
||||
staleSuccess bool,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
err := releaseGitHubSyncWithoutActivation(ctx, snapshot, target, status, lastError, expedite, target.RetryAt)
|
||||
if errors.Is(err, errSourceFinalFence) || (err == nil && staleSuccess) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New(lastError)
|
||||
}
|
||||
|
||||
func prepareGitHubSyncPackage(
|
||||
ctx context.Context,
|
||||
client githubReleaseAPI,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
) (*preparedGitHubSource, error) {
|
||||
limits := resolvePagesLimits(ctx)
|
||||
task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
download, err := client.Download(ctx, githubrelease.DownloadRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Asset: target.Asset,
|
||||
MaxBytes: limits.PackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] GitHub download failed: source_id=%d repo=%s asset=%s error=%v", snapshot.SourceID, snapshot.GitHubRepository, snapshot.AssetName, err)
|
||||
return nil, githubSourceDomainError(err)
|
||||
}
|
||||
prepared, err := inspectAndIngestGitHubPackage(ctx, snapshot, target, download, limits)
|
||||
if err != nil {
|
||||
if cleanupErr := download.Cleanup(); cleanupErr != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup GitHub package after preparation failure failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func inspectAndIngestGitHubPackage(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
download *githubrelease.DownloadResult,
|
||||
limits pagesLimits,
|
||||
) (*preparedGitHubSource, error) {
|
||||
if download.SHA256 == "" || download.Path == "" {
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if target.Detail.Digest != "" && "sha256:"+download.SHA256 != target.Detail.Digest {
|
||||
return nil, errors.New(errPagesSourceDigestMismatch)
|
||||
}
|
||||
format, ok := pagesarchive.DetectFormatFromName(target.Asset.Name)
|
||||
var err error
|
||||
if !ok {
|
||||
format, _, err = detectRemoteSourceFormat(download.Path, target.Asset.Name, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口")
|
||||
manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ingestState, err := resolveGitHubSourceIngest(ctx, snapshot, target, download, format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &preparedGitHubSource{
|
||||
target: target, download: download, format: format,
|
||||
manifest: manifest, ingestState: ingestState, limits: limits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveGitHubSourceIngest(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
download *githubrelease.DownloadResult,
|
||||
format pagesarchive.Format,
|
||||
) (*sourceIngestState, error) {
|
||||
if _, err := findSourceDeployment(ctx, snapshot.ProjectID, snapshot.SourceIdentity, target.Revision); err == nil {
|
||||
return &sourceIngestState{}, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包")
|
||||
result, err := ingestPagesDeploymentPackageWithSource(
|
||||
ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sourceIngestState{Result: result, HasIngest: true}, nil
|
||||
}
|
||||
|
||||
func releaseGitHubSyncWithoutActivation(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
target *githubSourceTarget,
|
||||
status string,
|
||||
lastError string,
|
||||
expedite bool,
|
||||
retryAt *time.Time,
|
||||
) error {
|
||||
now := time.Now()
|
||||
nextCheckAt := nextCheckAfterGitHubResponse(snapshot, now, retryAt)
|
||||
if expedite && snapshot.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
next := now.Add(initialCheckRetryDelay)
|
||||
if retryAt != nil && retryAt.After(next) {
|
||||
next = retryAt.UTC()
|
||||
}
|
||||
nextCheckAt = &next
|
||||
}
|
||||
updates := map[string]any{
|
||||
"last_seen_revision": target.Revision,
|
||||
"last_seen_detail": target.DetailJSON,
|
||||
sourceRuntimeColumnSyncStatus: status,
|
||||
sourceRuntimeColumnLastError: lastError,
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
"next_check_at": nextCheckAt,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
result := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?", snapshot.SourceID, snapshot.LeaseToken, now).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeGitHubSourceError(err error) string {
|
||||
if err == nil {
|
||||
return errPagesSourceSyncFailed
|
||||
}
|
||||
message := strings.TrimSpace(err.Error())
|
||||
for _, safeMessage := range []string{
|
||||
errPagesSourceSyncFailed,
|
||||
errPagesSourceReleaseNotFound,
|
||||
errPagesSourceDigestInvalid,
|
||||
errPagesSourceDigestMismatch,
|
||||
errPagesSourceConfirmationNeeded,
|
||||
errPagesSourceConfirmationStale,
|
||||
errPagesPackageURLTooLarge,
|
||||
errPagesPackageEmpty,
|
||||
errPagesPackageUnsupported,
|
||||
errPagesPackageInvalid,
|
||||
errPagesPackageExtractedTooLarge,
|
||||
errPagesPackageFileTooLarge,
|
||||
errPagesEntryFileMissing,
|
||||
} {
|
||||
if message == safeMessage {
|
||||
return safeMessage
|
||||
}
|
||||
}
|
||||
return errPagesSourceSyncFailed
|
||||
}
|
||||
|
||||
func githubSourceDomainError(err error) error {
|
||||
message := errPagesSourceSyncFailed
|
||||
retryAt, hasRetryAt := githubrelease.RetryAt(err)
|
||||
var retryDeadline *time.Time
|
||||
if hasRetryAt {
|
||||
retryDeadline = &retryAt
|
||||
}
|
||||
if err == nil {
|
||||
return &githubSourceProviderDomainError{message: message, permanent: false}
|
||||
}
|
||||
if githubrelease.IsDigestError(err) {
|
||||
message = errPagesSourceDigestMismatch
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
if githubrelease.IsNotFound(err) {
|
||||
message = errPagesSourceReleaseNotFound
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
if errors.Is(err, githubrelease.ErrAssetTooLarge) {
|
||||
message = errPagesPackageURLTooLarge
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
if errors.Is(err, githubrelease.ErrEmptyAsset) {
|
||||
message = errPagesPackageEmpty
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true}
|
||||
}
|
||||
return &githubSourceProviderDomainError{
|
||||
message: message, permanent: !githubrelease.IsRetryable(err), retryAt: retryDeadline,
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipGitHubActionRetry(err error) bool {
|
||||
var domainError *githubSourceProviderDomainError
|
||||
return errors.As(err, &domainError) && (domainError.permanent || domainError.retryAt != nil)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
func TestGitHubSourceIdentityLengthPrefixesFieldsAndResetsRuntime(t *testing.T) {
|
||||
firstInput := SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/OpenFlare/site",
|
||||
ReleaseSelector: githubReleaseSelectorTag,
|
||||
ReleaseTag: "release|foo",
|
||||
AssetName: "bar.zip",
|
||||
}
|
||||
secondInput := SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/OpenFlare/site",
|
||||
ReleaseSelector: githubReleaseSelectorTag,
|
||||
ReleaseTag: "release",
|
||||
AssetName: "foo|bar.zip",
|
||||
}
|
||||
firstConfig, err := buildGitHubSourceConfig(firstInput)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceConfig(first) error = %v, want nil", err)
|
||||
}
|
||||
secondConfig, err := buildGitHubSourceConfig(secondInput)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceConfig(second) error = %v, want nil", err)
|
||||
}
|
||||
legacyIdentityInput := func(config githubSourceConfig) string {
|
||||
return "github|" + config.Repository + "|" + config.Selector + "|" +
|
||||
config.Tag + "|" + config.AssetName
|
||||
}
|
||||
if firstLegacy, secondLegacy := legacyIdentityInput(firstConfig), legacyIdentityInput(secondConfig); firstLegacy != secondLegacy {
|
||||
t.Fatalf("legacy identity inputs differ: %q != %q; collision fixture is invalid", firstLegacy, secondLegacy)
|
||||
}
|
||||
if firstConfig.SourceIdentity == secondConfig.SourceIdentity {
|
||||
t.Fatalf("length-prefixed identities collide: %q", firstConfig.SourceIdentity)
|
||||
}
|
||||
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-identity-collision")
|
||||
firstSource, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, firstInput)
|
||||
if got, want := firstSource.SourceIdentity, firstConfig.SourceIdentity; got != want {
|
||||
t.Fatalf("first source identity = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
checkedAt := time.Now().Add(-time.Minute)
|
||||
syncedAt := time.Now().Add(-30 * time.Second)
|
||||
nextCheckAt := time.Now().Add(time.Hour)
|
||||
leaseExpiresAt := time.Now().Add(time.Minute)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", firstSource.ID).
|
||||
Updates(map[string]any{
|
||||
"etag": `"old-etag"`,
|
||||
"last_seen_revision": strings.Repeat("a", 64),
|
||||
"last_seen_detail": `{"provider":"github_release","tag":"release|foo"}`,
|
||||
"last_applied_revision": strings.Repeat("b", 64),
|
||||
"last_applied_detail": `{"provider":"github_release","tag":"older"}`,
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"last_error": "old error",
|
||||
"last_checked_at": &checkedAt,
|
||||
"last_synced_at": &syncedAt,
|
||||
"next_check_at": &nextCheckAt,
|
||||
"lease_expires_at": &leaseExpiresAt,
|
||||
"lease_token": "old-lease",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed runtime cursors error = %v, want nil", err)
|
||||
}
|
||||
|
||||
secondSource, runtime := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, secondInput)
|
||||
if secondSource.ID != firstSource.ID {
|
||||
t.Errorf("updated source ID = %d, want unchanged %d", secondSource.ID, firstSource.ID)
|
||||
}
|
||||
if got, want := secondSource.SourceIdentity, secondConfig.SourceIdentity; got != want {
|
||||
t.Errorf("updated source identity = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := secondSource.ConfigVersion, firstSource.ConfigVersion+1; got != want {
|
||||
t.Errorf("updated source config version = %d, want %d", got, want)
|
||||
}
|
||||
if runtime.ETag != "" || runtime.LastSeenRevision != "" || runtime.LastSeenDetail != "" ||
|
||||
runtime.LastAppliedRevision != "" || runtime.LastAppliedDetail != "" {
|
||||
t.Errorf("identity change retained runtime cursors: %+v", runtime)
|
||||
}
|
||||
if runtime.LastCheckedAt != nil || runtime.LastSyncedAt != nil || runtime.NextCheckAt != nil {
|
||||
t.Errorf(
|
||||
"identity change retained runtime timestamps: checked=%v synced=%v next=%v",
|
||||
runtime.LastCheckedAt,
|
||||
runtime.LastSyncedAt,
|
||||
runtime.NextCheckAt,
|
||||
)
|
||||
}
|
||||
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" ||
|
||||
runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
|
||||
t.Errorf(
|
||||
"identity change retained runtime state: status=%q error=%q lease=(%q, %v)",
|
||||
runtime.SyncStatus,
|
||||
runtime.LastError,
|
||||
runtime.LeaseToken,
|
||||
runtime.LeaseExpiresAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/hibiken/asynq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeGitHubReleaseClient struct {
|
||||
resolve func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
|
||||
download func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
|
||||
}
|
||||
|
||||
func (client *fakeGitHubReleaseClient) Resolve(
|
||||
ctx context.Context,
|
||||
request githubrelease.ResolveRequest,
|
||||
) (githubrelease.ResolveResult, error) {
|
||||
return client.resolve(ctx, request)
|
||||
}
|
||||
|
||||
func (client *fakeGitHubReleaseClient) Download(
|
||||
ctx context.Context,
|
||||
request githubrelease.DownloadRequest,
|
||||
) (*githubrelease.DownloadResult, error) {
|
||||
return client.download(ctx, request)
|
||||
}
|
||||
|
||||
func useFakeGitHubReleaseClient(t *testing.T, client githubReleaseAPI) {
|
||||
t.Helper()
|
||||
previous := newGitHubReleaseClient
|
||||
newGitHubReleaseClient = func() githubReleaseAPI { return client }
|
||||
t.Cleanup(func() { newGitHubReleaseClient = previous })
|
||||
}
|
||||
|
||||
func mustConfigureGitHubSourceWithoutDispatch(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
input SourceUpdateInput,
|
||||
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||
t.Helper()
|
||||
if err := validateGitHubSourceInput(input); err != nil {
|
||||
t.Fatalf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
||||
}
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
_, err := updateGitHubSourceTx(tx, projectID, input)
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("updateGitHubSourceTx(project=%d) error = %v, want nil", projectID, err)
|
||||
}
|
||||
return mustLoadPagesSource(t, ctx, projectID)
|
||||
}
|
||||
|
||||
func mustLoadPagesSource(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||
t.Helper()
|
||||
var source model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
|
||||
t.Fatalf("load source for project %d error = %v, want nil", projectID, err)
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
|
||||
t.Fatalf("load runtime for source %d error = %v, want nil", source.ID, err)
|
||||
}
|
||||
return &source, &runtime
|
||||
}
|
||||
|
||||
func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
setupPagesSourceDispatchTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-config")
|
||||
input := SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/OpenFlare/site.git",
|
||||
}
|
||||
result, err := UpdateSourceAs(ctx, project.ID, input, "user:42")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSourceAs(GitHub) error = %v, want nil", err)
|
||||
}
|
||||
if result.CheckTask == nil || result.CheckTask.Action != sourceActionCheck || result.Warning != "" {
|
||||
t.Errorf("UpdateSourceAs(GitHub) result = %+v, want initial check receipt without warning", result)
|
||||
}
|
||||
execution, err := model.GetTaskExecutionByTaskID(ctx, result.CheckTask.TaskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskExecutionByTaskID(%q) error = %v, want nil", result.CheckTask.TaskID, err)
|
||||
}
|
||||
var actionPayload SourceActionPayload
|
||||
if err := json.Unmarshal([]byte(execution.Payload), &actionPayload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(initial check payload) error = %v, want nil", err)
|
||||
}
|
||||
if actionPayload.Actor != "user:42" || actionPayload.Action != sourceActionCheck ||
|
||||
actionPayload.TargetRevision != "" || actionPayload.ConfirmedRevision != "" {
|
||||
t.Errorf("initial check payload = %+v, want real actor and credential-free check", actionPayload)
|
||||
}
|
||||
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if got, want := source.GitHubRepository, "OpenFlare/site"; got != want {
|
||||
t.Errorf("GitHubRepository = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.ReleaseSelector, githubReleaseSelectorLatest; got != want {
|
||||
t.Errorf("ReleaseSelector = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.AssetName, defaultGitHubAssetName; got != want {
|
||||
t.Errorf("AssetName = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.CheckIntervalMinutes, defaultCheckInterval; got != want {
|
||||
t.Errorf("CheckIntervalMinutes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := source.SourceIdentity, "dbbd25307aaa3b88bc25353476940a049428655bd8421ac63045fdcb5fb23c9d"; got != want {
|
||||
t.Errorf("SourceIdentity = %q, want %q", got, want)
|
||||
}
|
||||
if runtime.NextCheckAt == nil {
|
||||
t.Error("GitHub latest NextCheckAt = nil, want scheduled value")
|
||||
}
|
||||
|
||||
var taskCount int64
|
||||
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&taskCount).Error; err != nil {
|
||||
t.Fatalf("count initial checks error = %v, want nil", err)
|
||||
}
|
||||
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
||||
t.Fatalf("UpdateSourceAs(GitHub no-op) error = %v, want nil", err)
|
||||
}
|
||||
var noOpTaskCount int64
|
||||
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&noOpTaskCount).Error; err != nil {
|
||||
t.Fatalf("count no-op checks error = %v, want nil", err)
|
||||
}
|
||||
if noOpTaskCount != taskCount {
|
||||
t.Errorf("no-op initial check count = %d, want unchanged %d", noOpTaskCount, taskCount)
|
||||
}
|
||||
|
||||
secret := "provider-switch-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
|
||||
}
|
||||
remote, _ := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if remote.GitHubRepository != "" || remote.ReleaseSelector != "" || remote.AssetName != "" ||
|
||||
remote.AutoUpdateEnabled || remote.CheckIntervalMinutes != 0 {
|
||||
t.Errorf("Remote switched source retained GitHub fields: %+v", remote)
|
||||
}
|
||||
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
||||
t.Fatalf("UpdateSourceAs(Remote to GitHub) error = %v, want nil", err)
|
||||
}
|
||||
github, _ := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if github.RemoteURL != "" || github.RemoteNetworkPolicy != "" {
|
||||
t.Errorf("GitHub switched source retained Remote fields: URL=%q policy=%q", github.RemoteURL, github.RemoteNetworkPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-dispatch-warning")
|
||||
result, err := UpdateSourceAs(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
}, "user:9")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSourceAs(dispatch failure) error = %v, want saved source with warning", err)
|
||||
}
|
||||
if result.CheckTask != nil || result.Warning != errPagesSourceInitialCheckWarning {
|
||||
t.Errorf("UpdateSourceAs(dispatch failure) result = %+v, want warning and nil check task", result)
|
||||
}
|
||||
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if source.GitHubRepository != "a/b" || runtime.SyncStatus != pagesSourceStatusFailed ||
|
||||
runtime.LastError != errPagesSourceInitialCheckWarning {
|
||||
t.Errorf("saved source/runtime = repo:%q status:%q error:%q", source.GitHubRepository, runtime.SyncStatus, runtime.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSourceRejectsUnsafeOrPhaseThreeFields(t *testing.T) {
|
||||
tests := []SourceUpdateInput{
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "http://github.com/a/b"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a%20b/repo"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/extra"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com//a/b"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b?"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b#"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\n.zip"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\u202e.zip"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dir/dist.zip"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AutoUpdateEnabled: true},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", CheckIntervalMinutes: 60},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: " v1"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\n"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\u2028draft"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: `v1\draft`},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release//v1"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/.draft"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/v1.lock"},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "latest", ReleaseTag: "v1"},
|
||||
}
|
||||
for _, input := range tests {
|
||||
if err := validateGitHubSourceInput(input); err == nil {
|
||||
t.Errorf("validateGitHubSourceInput(%+v) error = nil, want non-nil", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSourceAcceptsLegalAssetAndTagCharacters(t *testing.T) {
|
||||
tests := []SourceUpdateInput{
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: "dist?channel=stable&part#1.zip",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: " dist.zip ",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
ReleaseSelector: "tag",
|
||||
ReleaseTag: "release/v1#stable&build=1",
|
||||
AssetName: "dist.zip",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b.git",
|
||||
ReleaseSelector: "tag",
|
||||
ReleaseTag: "@",
|
||||
AssetName: "dist.zip",
|
||||
},
|
||||
{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
ReleaseSelector: "tag",
|
||||
ReleaseTag: "release/v1.LOCK",
|
||||
AssetName: "dist.zip",
|
||||
},
|
||||
}
|
||||
for _, input := range tests {
|
||||
if err := validateGitHubSourceInput(input); err != nil {
|
||||
t.Errorf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialCheckFailureUsesExactConfigFence(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-initial-fence")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
staleVersion := source.ConfigVersion
|
||||
if err := db.DB(ctx).Model(source).Update("config_version", staleVersion+1).Error; err != nil {
|
||||
t.Fatalf("increment source config version error = %v, want nil", err)
|
||||
}
|
||||
markInitialCheckDispatchFailed(ctx, source.ID, staleVersion)
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" {
|
||||
t.Errorf("stale initial failure runtime = status:%q error:%q, want unchanged idle", runtime.SyncStatus, runtime.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckUsesETagAndDetectsSameReleaseReplacement(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-check")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
appliedRevision := strings.Repeat("a", 64)
|
||||
appliedDetail := `{"provider":"github","release_id":"100","asset_id":"1","tag":"release/v1","asset_name":"dist.zip"}`
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"etag": `"old-etag"`,
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": appliedDetail,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed GitHub runtime error = %v, want nil", err)
|
||||
}
|
||||
updatedAt := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
|
||||
var gotETag string
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
gotETag = request.ETag
|
||||
return githubrelease.ResolveResult{
|
||||
ETag: `"new-etag"`,
|
||||
Release: githubrelease.Release{ID: "100", Tag: "release/v1"},
|
||||
Asset: githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt},
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called during check, want resolve only")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
if err != nil || outcome != sourceLeaseAcquired {
|
||||
t.Fatalf("acquire check lease = (%+v, %q, %v), want acquired", snapshot, outcome, err)
|
||||
}
|
||||
result, err := checkGitHubSource(ctx, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("checkGitHubSource() error = %v, want nil", err)
|
||||
}
|
||||
if result.Stale {
|
||||
t.Error("checkGitHubSource() stale = true, want false")
|
||||
}
|
||||
if got, want := gotETag, `"old-etag"`; got != want {
|
||||
t.Errorf("Resolve ETag = %q, want %q", got, want)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusAttention || runtime.LastSeenRevision == "" {
|
||||
t.Errorf("replacement runtime = status:%q seen:%q, want attention with revision", runtime.SyncStatus, runtime.LastSeenRevision)
|
||||
}
|
||||
view, err := GetSource(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource() error = %v, want nil", err)
|
||||
}
|
||||
if view.LastSeen == nil || view.LastSeen.Label != "release/v1" {
|
||||
t.Errorf("LastSeen = %+v, want full tag with slash", view.LastSeen)
|
||||
}
|
||||
if err := preflightGitHubSyncConfirmation(ctx, source.ID, ""); err == nil || err.Error() != errPagesSourceConfirmationNeeded {
|
||||
t.Errorf("preflight without confirmation error = %v, want %q", err, errPagesSourceConfirmationNeeded)
|
||||
}
|
||||
if err := preflightGitHubSyncConfirmation(ctx, source.ID, runtime.LastSeenRevision); err != nil {
|
||||
t.Errorf("preflight exact confirmation error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckNotModifiedRefreshesRuntimeWithoutDeployment(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-304")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{NotModified: true, ETag: `"same"`}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called for 304 check")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
if _, err := checkGitHubSource(ctx, snapshot); err != nil {
|
||||
t.Fatalf("checkGitHubSource(304) error = %v, want nil", err)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.ETag != `"same"` || runtime.LastCheckedAt == nil || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
||||
t.Errorf("304 runtime = %+v, want refreshed timestamps/etag and released lease", runtime)
|
||||
}
|
||||
var deployments int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
||||
t.Fatalf("count deployments error = %v, want nil", err)
|
||||
}
|
||||
if deployments != 0 {
|
||||
t.Errorf("deployments after check = %d, want 0", deployments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubTargetMismatchPreservesAttentionAndExpeditesRecheck(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-target-mismatch")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
appliedRevision := strings.Repeat("a", 64)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": `{"provider":"github","release_id":"100","asset_id":"1","tag":"v1","asset_name":"dist.zip"}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed applied runtime error = %v, want nil", err)
|
||||
}
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{
|
||||
Release: githubrelease.Release{ID: "100", Tag: "v1"},
|
||||
Asset: githubrelease.Asset{
|
||||
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
RetryAt: &retryAt,
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after target mismatch")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
outcome, err := syncGitHubSource(ctx, snapshot, pagesSourceCreatedBySystem, strings.Repeat("b", 64), "")
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource(target mismatch) error = %v, want nil stale outcome", err)
|
||||
}
|
||||
if outcome == nil || !outcome.Stale {
|
||||
t.Errorf("syncGitHubSource(target mismatch) = %+v, want stale", outcome)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusAttention {
|
||||
t.Errorf("target mismatch SyncStatus = %q, want %q", runtime.SyncStatus, pagesSourceStatusAttention)
|
||||
}
|
||||
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
||||
t.Errorf("target mismatch NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
||||
}
|
||||
var deployments int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
||||
t.Fatalf("count mismatch deployments error = %v, want nil", err)
|
||||
}
|
||||
if deployments != 0 {
|
||||
t.Errorf("target mismatch deployments = %d, want 0", deployments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckLostLeaseReturnsStaleWithoutOverwritingRuntime(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-check-fence")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{}, errors.New("transient provider failure")
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
return nil, errors.New("unexpected")
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"lease_token": "new-owner",
|
||||
"lease_expires_at": time.Now().Add(time.Minute),
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"last_error": "new-owner-state",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("replace lease owner error = %v, want nil", err)
|
||||
}
|
||||
result, err := checkGitHubSource(ctx, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("checkGitHubSource(lost lease) error = %v, want stale no-op", err)
|
||||
}
|
||||
if result == nil || !result.Stale {
|
||||
t.Errorf("checkGitHubSource(lost lease) = %+v, want stale", result)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.LeaseToken != "new-owner" || runtime.LastError != "new-owner-state" || runtime.SyncStatus != pagesSourceStatusSyncing {
|
||||
t.Errorf("lost lease runtime = token:%q error:%q status:%q, want new owner state", runtime.LeaseToken, runtime.LastError, runtime.SyncStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckRateLimitUsesServerDeadlineAndSuppressesFastRetry(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-rate-limit")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
||||
Kind: githubrelease.ErrMetadata, StatusCode: 429, RetryAt: &retryAt,
|
||||
}
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
return nil, errors.New("unexpected")
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
_, err := checkGitHubSource(ctx, snapshot)
|
||||
if err == nil || err.Error() != errPagesSourceSyncFailed {
|
||||
t.Fatalf("checkGitHubSource(rate limit) error = %v, want safe sync failure", err)
|
||||
}
|
||||
if !shouldSkipGitHubActionRetry(err) {
|
||||
t.Error("shouldSkipGitHubActionRetry(rate limit) = false, want true")
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.SyncStatus != pagesSourceStatusFailed {
|
||||
t.Errorf("rate limit runtime = next:%v status:%q, want deadline >= %v and failed", runtime.NextCheckAt, runtime.SyncStatus, retryAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-check-target")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{
|
||||
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
||||
Asset: githubrelease.Asset{
|
||||
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
||||
},
|
||||
RetryAt: &retryAt,
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after invalid check target")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
||||
_, err := checkGitHubSource(ctx, snapshot)
|
||||
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
||||
t.Fatalf("checkGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
||||
}
|
||||
if !isPermanentSourceSyncError(err) {
|
||||
t.Error("invalid check target classification = retryable, want permanent")
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
||||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
||||
t.Errorf(
|
||||
"invalid check target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
||||
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
||||
errPagesSourceDigestInvalid, retryAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-sync-target")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{
|
||||
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
||||
Asset: githubrelease.Asset{
|
||||
ID: "2", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
||||
},
|
||||
RetryAt: &retryAt,
|
||||
}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after invalid sync target")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
_, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
||||
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
||||
t.Fatalf("syncGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
||||
}
|
||||
if !isPermanentSourceSyncError(err) {
|
||||
t.Error("invalid sync target classification = retryable, want permanent")
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
||||
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
||||
t.Errorf(
|
||||
"invalid sync target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
||||
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
||||
errPagesSourceDigestInvalid, retryAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCheckHandlerSkipsProviderFastRetry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
retryDate bool
|
||||
}{
|
||||
{name: "bad request", status: http.StatusBadRequest},
|
||||
{name: "rate limited forbidden", status: http.StatusForbidden, retryDate: true},
|
||||
{name: "too many requests", status: http.StatusTooManyRequests, retryDate: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-handler-"+strings.ReplaceAll(test.name, " ", "-"))
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
var retryAt *time.Time
|
||||
if test.retryDate {
|
||||
deadline := time.Now().Add(2 * time.Hour).UTC()
|
||||
retryAt = &deadline
|
||||
}
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
||||
Kind: githubrelease.ErrMetadata, StatusCode: test.status, RetryAt: retryAt,
|
||||
}
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called after provider check failure")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
raw, err := json.Marshal(SourceActionPayload{
|
||||
SourceID: source.ID, ConfigVersion: source.ConfigVersion,
|
||||
Action: sourceActionCheck, Actor: "user:7",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(check payload) error = %v, want nil", err)
|
||||
}
|
||||
result, err := (&SourceActionHandler{}).Execute(ctx, raw)
|
||||
if result != nil || err == nil || !errors.Is(err, asynq.SkipRetry) {
|
||||
t.Fatalf("SourceActionHandler.Execute(status %d) = result:%+v error:%v, want SkipRetry", test.status, result, err)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
||||
t.Errorf("provider failure runtime = status:%q next:%v lease:%q", runtime.SyncStatus, runtime.NextCheckAt, runtime.LeaseToken)
|
||||
}
|
||||
if retryAt != nil && (runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(*retryAt)) {
|
||||
t.Errorf("provider failure NextCheckAt = %v, want deadline >= %v", runtime.NextCheckAt, *retryAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncActivatesWithMetadataRevisionAndPackageChecksum(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-sync")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: "dist.zip",
|
||||
})
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "github-v1"})
|
||||
packageHash := sha256.Sum256(packageBytes)
|
||||
updatedAt := time.Date(2026, 7, 19, 11, 0, 0, 0, time.UTC)
|
||||
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
||||
release := githubrelease.Release{ID: "200", Tag: "release/v2"}
|
||||
asset := githubrelease.Asset{ID: "10", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt}
|
||||
client := &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{Release: release, Asset: asset, RetryAt: &retryAt}, nil
|
||||
},
|
||||
download: func(_ context.Context, request githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
path := filepath.Join(t.TempDir(), "download")
|
||||
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile(download) error = %v, want nil", err)
|
||||
}
|
||||
return &githubrelease.DownloadResult{
|
||||
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
useFakeGitHubReleaseClient(t, client)
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
outcome, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource() error = %v, want nil", err)
|
||||
}
|
||||
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
||||
t.Fatalf("syncGitHubSource() = %+v, want active deployment", outcome)
|
||||
}
|
||||
deployment, err := model.GetPagesDeploymentByID(ctx, outcome.Deployment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", outcome.Deployment.ID, err)
|
||||
}
|
||||
if got, want := deployment.Checksum, hex.EncodeToString(packageHash[:]); got != want {
|
||||
t.Errorf("deployment Checksum = %q, want package hash %q", got, want)
|
||||
}
|
||||
if deployment.SourceRevision == nil || *deployment.SourceRevision == deployment.Checksum {
|
||||
t.Errorf("deployment SourceRevision = %v, want metadata revision distinct from package checksum", deployment.SourceRevision)
|
||||
}
|
||||
if got, want := deployment.SourceLabel, "release/v2"; got != want {
|
||||
t.Errorf("deployment SourceLabel = %q, want %q", got, want)
|
||||
}
|
||||
if deployment.SourceType != PagesSourceTypeGitHubRelease || deployment.TriggerType != pagesSourceTriggerManualSync ||
|
||||
deployment.CreatedBy != "user:7" {
|
||||
t.Errorf("deployment provenance = type:%q trigger:%q actor:%q", deployment.SourceType, deployment.TriggerType, deployment.CreatedBy)
|
||||
}
|
||||
if strings.Contains(deployment.SourceMeta, "http") || strings.Contains(deployment.SourceMeta, "token") {
|
||||
t.Errorf("deployment SourceMeta = %q, want no URL or token", deployment.SourceMeta)
|
||||
}
|
||||
if !strings.Contains(deployment.SourceMeta, `"tag":"release/v2"`) || strings.Contains(deployment.SourceMeta, `"label"`) {
|
||||
t.Errorf("deployment SourceMeta = %q, want provider-specific tag field", deployment.SourceMeta)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
||||
t.Errorf("sync runtime NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
||||
}
|
||||
|
||||
secondSnapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
second, err := syncGitHubSource(ctx, secondSnapshot, "user:7", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource(idempotent) error = %v, want nil", err)
|
||||
}
|
||||
if second == nil || !second.Reused || second.Deployment == nil || second.Deployment.ID != outcome.Deployment.ID {
|
||||
t.Errorf("syncGitHubSource(idempotent) = %+v, want reused deployment %d", second, outcome.Deployment.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncActivatesExactConfirmedReplacement(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-replacement")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
AssetName: "dist.zip",
|
||||
})
|
||||
release := githubrelease.Release{ID: "300", Tag: "v3"}
|
||||
asset := githubrelease.Asset{
|
||||
ID: "12", Name: "dist.zip", State: "uploaded",
|
||||
UpdatedAt: time.Date(2026, 7, 19, 13, 0, 0, 0, time.UTC),
|
||||
}
|
||||
target, err := buildGitHubSourceTarget(release, asset, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceTarget() error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
||||
"last_seen_revision": target.Revision,
|
||||
"last_seen_detail": target.DetailJSON,
|
||||
"last_applied_revision": strings.Repeat("a", 64),
|
||||
"last_applied_detail": `{"provider":"github","release_id":"300","asset_id":"11","tag":"v3","asset_name":"dist.zip"}`,
|
||||
"sync_status": pagesSourceStatusAttention,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed replacement cursor error = %v, want nil", err)
|
||||
}
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "confirmed-v3"})
|
||||
packageHash := sha256.Sum256(packageBytes)
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{Release: release, Asset: asset}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
path := filepath.Join(t.TempDir(), "confirmed.zip")
|
||||
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile(confirmed package) error = %v, want nil", err)
|
||||
}
|
||||
return &githubrelease.DownloadResult{
|
||||
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
outcome, err := syncGitHubSource(ctx, snapshot, "user:9", "", target.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("syncGitHubSource(confirmed replacement) error = %v, want nil", err)
|
||||
}
|
||||
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
||||
t.Fatalf("syncGitHubSource(confirmed replacement) = %+v, want active deployment", outcome)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastAppliedRevision != target.Revision {
|
||||
t.Errorf("confirmed replacement runtime = status:%q applied:%q, want idle/%q", runtime.SyncStatus, runtime.LastAppliedRevision, target.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceActionPayloadSeparatesSystemTargetAndUserConfirmation(t *testing.T) {
|
||||
handler := &SourceActionHandler{}
|
||||
revision := strings.Repeat("a", 64)
|
||||
invalid := []SourceActionPayload{
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TargetRevision: revision},
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, ConfirmedRevision: revision},
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision, ConfirmedRevision: revision},
|
||||
}
|
||||
for _, payload := range invalid {
|
||||
raw, _ := json.Marshal(payload)
|
||||
if normalized, err := handler.ValidatePayload(raw); err == nil {
|
||||
t.Errorf("ValidatePayload(%+v) = %s, nil; want error", payload, normalized)
|
||||
}
|
||||
}
|
||||
valid := []SourceActionPayload{
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision},
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", ConfirmedRevision: revision},
|
||||
}
|
||||
for _, payload := range valid {
|
||||
raw, _ := json.Marshal(payload)
|
||||
if _, err := handler.ValidatePayload(raw); err != nil {
|
||||
t.Errorf("ValidatePayload(%+v) error = %v, want nil", payload, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubProviderErrorsMapToSafeRetryClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider error
|
||||
want string
|
||||
permanent bool
|
||||
skipRetry bool
|
||||
}{
|
||||
{
|
||||
name: "asset missing", provider: &githubrelease.Error{Kind: githubrelease.ErrAssetNotFound, StatusCode: 200},
|
||||
want: errPagesSourceReleaseNotFound, permanent: true, skipRetry: true,
|
||||
},
|
||||
{
|
||||
name: "digest mismatch", provider: &githubrelease.Error{Kind: githubrelease.ErrDigestMismatch, StatusCode: 200},
|
||||
want: errPagesSourceDigestMismatch, permanent: true, skipRetry: true,
|
||||
},
|
||||
{
|
||||
name: "rate limit", provider: &githubrelease.Error{
|
||||
Kind: githubrelease.ErrMetadata, StatusCode: 429,
|
||||
RetryAt: func() *time.Time { value := time.Now().Add(time.Hour); return &value }(),
|
||||
},
|
||||
want: errPagesSourceSyncFailed, permanent: false, skipRetry: true,
|
||||
},
|
||||
{
|
||||
name: "network", provider: &githubrelease.Error{Kind: githubrelease.ErrDownload},
|
||||
want: errPagesSourceSyncFailed, permanent: false, skipRetry: false,
|
||||
},
|
||||
{
|
||||
name: "forbidden without retry", provider: &githubrelease.Error{Kind: githubrelease.ErrMetadata, StatusCode: 403},
|
||||
want: errPagesSourceSyncFailed, permanent: true, skipRetry: true,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
domainErr := githubSourceDomainError(test.provider)
|
||||
if got := domainErr.Error(); got != test.want {
|
||||
t.Errorf("githubSourceDomainError() = %q, want %q", got, test.want)
|
||||
}
|
||||
var typedDomainError *githubSourceProviderDomainError
|
||||
if !errors.As(domainErr, &typedDomainError) {
|
||||
t.Fatalf("githubSourceDomainError() type = %T, want *githubSourceProviderDomainError", domainErr)
|
||||
}
|
||||
if got := typedDomainError.permanent; got != test.permanent {
|
||||
t.Errorf("githubSourceProviderDomainError.permanent = %t, want %t", got, test.permanent)
|
||||
}
|
||||
if got := shouldSkipGitHubActionRetry(domainErr); got != test.skipRetry {
|
||||
t.Errorf("shouldSkipGitHubActionRetry() = %t, want %t", got, test.skipRetry)
|
||||
}
|
||||
if strings.Contains(domainErr.Error(), "status=") || strings.Contains(domainErr.Error(), "repo=") {
|
||||
t.Errorf("githubSourceDomainError() = %q, want stable Pages message", domainErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSyncRejectsStaleConfirmationWithoutChangingActive(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-stale")
|
||||
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/a/b",
|
||||
})
|
||||
asset := githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: time.Now().UTC()}
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
return githubrelease.ResolveResult{Release: githubrelease.Release{ID: "1", Tag: "v1"}, Asset: asset}, nil
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("Download called for stale confirmation")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
_, err := syncGitHubSource(ctx, snapshot, "user:1", "", strings.Repeat("f", 64))
|
||||
if err == nil || err.Error() != errPagesSourceConfirmationStale {
|
||||
t.Errorf("syncGitHubSource(stale confirmation) error = %v, want %q", err, errPagesSourceConfirmationStale)
|
||||
}
|
||||
storedProject, loadErr := model.GetPagesProjectByID(ctx, project.ID)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("GetPagesProjectByID() error = %v, want nil", loadErr)
|
||||
}
|
||||
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != oldActive.ID {
|
||||
t.Errorf("ActiveDeploymentID = %v, want old active %d", storedProject.ActiveDeploymentID, oldActive.ID)
|
||||
}
|
||||
}
|
||||
@@ -822,8 +822,8 @@ func fenceDeploymentActivationSource(
|
||||
audit.SourceIdentity = state.Source.SourceIdentity
|
||||
audit.AutoDisabled = state.Source.AutoUpdateEnabled
|
||||
if err := tx.Model(state.Source).Updates(map[string]any{
|
||||
"config_version": state.Source.ConfigVersion + 1,
|
||||
"auto_update_enabled": false,
|
||||
sourceColumnConfigVersion: state.Source.ConfigVersion + 1,
|
||||
sourceColumnAutoUpdateEnabled: false,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -856,8 +856,8 @@ func switchActiveDeploymentTx(
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(deployment).Updates(map[string]any{
|
||||
"status": model.PagesDeploymentStatusActive,
|
||||
"activated_at": &now,
|
||||
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
|
||||
"activated_at": &now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -45,6 +45,17 @@ func handleSourceLogicError(c *gin.Context, err error) bool {
|
||||
errPagesSourceRemoteURLMode,
|
||||
errPagesSourceRemoteURLInvalid,
|
||||
errPagesSourceNetworkPolicy,
|
||||
errPagesSourceGitHubFields,
|
||||
errPagesSourceRepositoryInvalid,
|
||||
errPagesSourceSelectorInvalid,
|
||||
errPagesSourceAssetNameInvalid,
|
||||
errPagesSourceCheckInterval,
|
||||
errPagesSourceAutoNotAvailable,
|
||||
errPagesSourceReleaseNotFound,
|
||||
errPagesSourceDigestInvalid,
|
||||
errPagesSourceDigestMismatch,
|
||||
errPagesSourceConfirmationNeeded,
|
||||
errPagesSourceConfirmationStale,
|
||||
errPagesSourceCheckUnsupported,
|
||||
errPagesSourceActionInvalid:
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
@@ -253,7 +264,7 @@ func GetSourceHandler(c *gin.Context) {
|
||||
|
||||
// UpdateSourceHandler 创建或更新 Pages 项目部署源。
|
||||
// @Summary 更新 Pages 部署源
|
||||
// @Description Phase 1 支持 Remote URL 来源;完整地址仅写入,不会在响应中返回
|
||||
// @Description 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
|
||||
// @Tags openflare-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -275,7 +286,11 @@ func UpdateSourceHandler(c *gin.Context) {
|
||||
if !decodeStrictJSON(c, &input, false) {
|
||||
return
|
||||
}
|
||||
result, err := UpdateSource(c.Request.Context(), projectID, input)
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := UpdateSourceAs(c.Request.Context(), projectID, input, actor)
|
||||
if handleSourceLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
@@ -309,7 +324,7 @@ func DeleteSourceHandler(c *gin.Context) {
|
||||
|
||||
// CheckSourceHandler 请求检查 Pages 部署源。
|
||||
// @Summary 检查 Pages 部署源
|
||||
// @Description Remote URL 来源不支持检查更新;该端点为后续 GitHub Release 来源保留统一动作契约
|
||||
// @Description 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
PagesSourceTypeManual = "manual"
|
||||
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
|
||||
PagesSourceTypeRemoteURL = "remote_url"
|
||||
// PagesSourceTypeGitHubRelease is reserved for Phase 2.
|
||||
// PagesSourceTypeGitHubRelease represents a public GitHub Release asset.
|
||||
PagesSourceTypeGitHubRelease = "github_release"
|
||||
|
||||
pagesSourceStatusIdle = "idle"
|
||||
@@ -37,6 +37,10 @@ const (
|
||||
pagesSourceStatusAttention = "attention"
|
||||
|
||||
defaultRemoteAssetLabel = "pages-package"
|
||||
defaultGitHubAssetName = "dist.zip"
|
||||
defaultCheckInterval = 60
|
||||
minimumCheckInterval = 5
|
||||
maximumCheckInterval = 1440
|
||||
)
|
||||
|
||||
// SourceUpdateInput is the discriminated source configuration payload.
|
||||
@@ -72,7 +76,7 @@ type SourceView struct {
|
||||
ReleaseSelector string `json:"release_selector,omitempty"`
|
||||
ReleaseTag string `json:"release_tag,omitempty"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled,omitempty"`
|
||||
AutoUpdateEnabled *bool `json:"auto_update_enabled,omitempty"`
|
||||
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
|
||||
SyncStatus string `json:"sync_status,omitempty"`
|
||||
UpdateAvailable bool `json:"update_available,omitempty"`
|
||||
@@ -99,10 +103,15 @@ type SourceUpdateResult struct {
|
||||
}
|
||||
|
||||
type sourceDetail struct {
|
||||
Provider string `json:"provider"`
|
||||
Label string `json:"label"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
ReleaseID string `json:"release_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
LegacyLabel string `json:"label,omitempty"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
ReleaseID string `json:"release_id,omitempty"`
|
||||
AssetID string `json:"asset_id,omitempty"`
|
||||
AssetUpdatedAt string `json:"asset_updated_at,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
}
|
||||
|
||||
type remoteSourceConfig struct {
|
||||
@@ -126,14 +135,43 @@ func GetSource(ctx context.Context, projectID uint) (*SourceView, error) {
|
||||
return buildSourceView(source, runtime)
|
||||
}
|
||||
|
||||
// UpdateSource creates or updates a Remote URL source and its 1:1 runtime row.
|
||||
// UpdateSource creates or updates a source. Direct callers use the system actor;
|
||||
// HTTP handlers should call UpdateSourceAs so the initial check is auditable.
|
||||
func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput) (*SourceUpdateResult, error) {
|
||||
if err := validateRemoteSourceInput(input); err != nil {
|
||||
return UpdateSourceAs(ctx, projectID, input, pagesSourceCreatedBySystem)
|
||||
}
|
||||
|
||||
// UpdateSourceAs persists source configuration and queues the first GitHub check
|
||||
// after commit when the GitHub configuration was materially changed.
|
||||
func UpdateSourceAs(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
input SourceUpdateInput,
|
||||
actor string,
|
||||
) (*SourceUpdateResult, error) {
|
||||
if !validPagesSourceActor(actor) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if err := validateSourceUpdateInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changed := false
|
||||
var persistedSource model.PagesProjectSource
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return updateRemoteSourceTx(tx, projectID, input)
|
||||
var err error
|
||||
switch strings.TrimSpace(input.SourceType) {
|
||||
case PagesSourceTypeRemoteURL:
|
||||
changed, err = updateRemoteSourceTx(tx, projectID, input)
|
||||
case PagesSourceTypeGitHubRelease:
|
||||
changed, err = updateGitHubSourceTx(tx, projectID, input)
|
||||
default:
|
||||
err = errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if err != nil || !changed || strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||
return err
|
||||
}
|
||||
return tx.Where("project_id = ?", projectID).First(&persistedSource).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -143,24 +181,34 @@ func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SourceUpdateResult{Source: view, Warning: ""}, nil
|
||||
result := &SourceUpdateResult{Source: view, Warning: ""}
|
||||
if changed && strings.TrimSpace(input.SourceType) == PagesSourceTypeGitHubRelease {
|
||||
receipt, dispatchErr := dispatchSourceActionSnapshot(ctx, persistedSource, sourceActionCheck, actor, "", "", "manual")
|
||||
if dispatchErr != nil {
|
||||
result.Warning = errPagesSourceInitialCheckWarning
|
||||
markInitialCheckDispatchFailed(ctx, persistedSource.ID, persistedSource.ConfigVersion)
|
||||
} else {
|
||||
result.CheckTask = receipt
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) error {
|
||||
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if !hasExisting {
|
||||
return createRemoteSourceTx(tx, projectID, config)
|
||||
return true, createRemoteSourceTx(tx, projectID, config)
|
||||
}
|
||||
return updateExistingRemoteSourceTx(tx, existing, config)
|
||||
}
|
||||
@@ -227,33 +275,33 @@ func updateExistingRemoteSourceTx(
|
||||
tx *gorm.DB,
|
||||
existing *model.PagesProjectSource,
|
||||
config remoteSourceConfig,
|
||||
) error {
|
||||
) (bool, error) {
|
||||
if !remoteSourceConfigChanged(existing, config) {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("source_id = ?", existing.ID).
|
||||
First(&runtime).Error; err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
identityChanged := existing.SourceIdentity != config.Identity
|
||||
if err := tx.Model(existing).Updates(map[string]any{
|
||||
"source_type": PagesSourceTypeRemoteURL,
|
||||
"remote_url": config.URL,
|
||||
"remote_network_policy": config.Policy,
|
||||
"github_repository": "",
|
||||
"release_selector": "",
|
||||
"release_tag": "",
|
||||
"asset_name": "",
|
||||
"auto_update_enabled": false,
|
||||
"check_interval_minutes": 0,
|
||||
"config_version": existing.ConfigVersion + 1,
|
||||
"source_identity": config.Identity,
|
||||
"source_type": PagesSourceTypeRemoteURL,
|
||||
"remote_url": config.URL,
|
||||
"remote_network_policy": config.Policy,
|
||||
"github_repository": "",
|
||||
"release_selector": "",
|
||||
"release_tag": "",
|
||||
"asset_name": "",
|
||||
sourceColumnAutoUpdateEnabled: false,
|
||||
"check_interval_minutes": 0,
|
||||
sourceColumnConfigVersion: existing.ConfigVersion + 1,
|
||||
"source_identity": config.Identity,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
return resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
|
||||
return true, resetRuntimeAfterSourceUpdate(tx, &runtime, identityChanged)
|
||||
}
|
||||
|
||||
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
||||
@@ -328,6 +376,19 @@ func validateRemoteSourceInput(input SourceUpdateInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSourceUpdateInput(input SourceUpdateInput) error {
|
||||
switch strings.TrimSpace(input.SourceType) {
|
||||
case PagesSourceTypeRemoteURL:
|
||||
return validateRemoteSourceInput(input)
|
||||
case PagesSourceTypeGitHubRelease:
|
||||
return validateGitHubSourceInput(input)
|
||||
case "":
|
||||
return errors.New(errPagesSourceTypeRequired)
|
||||
default:
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
|
||||
if input.RemoteURLSet {
|
||||
return strings.TrimSpace(input.RemoteURL), nil
|
||||
@@ -430,7 +491,8 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
||||
view.ReleaseSelector = source.ReleaseSelector
|
||||
view.ReleaseTag = source.ReleaseTag
|
||||
view.AssetName = source.AssetName
|
||||
view.AutoUpdateEnabled = source.AutoUpdateEnabled
|
||||
autoUpdateEnabled := source.AutoUpdateEnabled
|
||||
view.AutoUpdateEnabled = &autoUpdateEnabled
|
||||
view.CheckIntervalMinutes = source.CheckIntervalMinutes
|
||||
default:
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
@@ -441,7 +503,7 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
||||
func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
||||
detail := sourceDetail{}
|
||||
_ = unmarshalSourceDetail(detailJSON, &detail)
|
||||
label := strings.TrimSpace(detail.Label)
|
||||
label := sourceDetailLabel(detail)
|
||||
if label == "" {
|
||||
label = defaultRemoteAssetLabel
|
||||
}
|
||||
@@ -452,6 +514,19 @@ func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceDetailLabel(detail sourceDetail) string {
|
||||
if detail.Provider == githubSourceDetailProvider {
|
||||
if label := strings.TrimSpace(detail.Tag); label != "" {
|
||||
return label
|
||||
}
|
||||
return strings.TrimSpace(detail.LegacyLabel)
|
||||
}
|
||||
if label := strings.TrimSpace(detail.DisplayName); label != "" {
|
||||
return label
|
||||
}
|
||||
return strings.TrimSpace(detail.LegacyLabel)
|
||||
}
|
||||
|
||||
func unmarshalSourceDetail(raw string, detail *sourceDetail) error {
|
||||
if detail == nil || strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
|
||||
@@ -24,10 +24,14 @@ const (
|
||||
sourceRuntimeErrorMaxBytes = 512
|
||||
sourceRevisionHexLength = 64
|
||||
|
||||
sourceColumnAutoUpdateEnabled = "auto_update_enabled"
|
||||
sourceColumnConfigVersion = "config_version"
|
||||
sourceRuntimeColumnSyncStatus = "sync_status"
|
||||
sourceRuntimeColumnLastError = "last_error"
|
||||
sourceRuntimeColumnLastCheckedAt = "last_checked_at"
|
||||
sourceRuntimeColumnLeaseToken = "lease_token"
|
||||
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
|
||||
pagesDeploymentColumnStatus = "status"
|
||||
)
|
||||
|
||||
type sourceLeaseOutcome string
|
||||
@@ -50,6 +54,16 @@ type sourceExecutionSnapshot struct {
|
||||
SourceIdentity string
|
||||
RemoteURL string
|
||||
RemoteNetworkPolicy string
|
||||
GitHubRepository string
|
||||
ReleaseSelector string
|
||||
ReleaseTag string
|
||||
AssetName string
|
||||
CheckIntervalMinutes int
|
||||
ETag string
|
||||
LastSeenRevision string
|
||||
LastSeenDetail string
|
||||
LastAppliedRevision string
|
||||
LastAppliedDetail string
|
||||
RootDir string
|
||||
EntryFile string
|
||||
LeaseToken string
|
||||
@@ -161,6 +175,16 @@ func loadSourceExecutionSnapshot(
|
||||
SourceIdentity: source.SourceIdentity,
|
||||
RemoteURL: source.RemoteURL,
|
||||
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
|
||||
GitHubRepository: source.GitHubRepository,
|
||||
ReleaseSelector: source.ReleaseSelector,
|
||||
ReleaseTag: source.ReleaseTag,
|
||||
AssetName: source.AssetName,
|
||||
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,
|
||||
|
||||
@@ -226,12 +226,14 @@ func syncRemoteSource(
|
||||
ctx,
|
||||
snapshot,
|
||||
prepared.Candidate.Checksum,
|
||||
prepared.Candidate.Checksum,
|
||||
prepared.Detail,
|
||||
prepared.DetailJSON,
|
||||
actor,
|
||||
prepared.Manifest,
|
||||
ingestState.Result,
|
||||
ingestState.HasIngest,
|
||||
nil,
|
||||
)
|
||||
ingestState.Referenced = referenced
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
@@ -288,7 +290,7 @@ func prepareRemoteSource(
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: safeRemoteSourceLabel(candidate.SafeLabel)}
|
||||
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: safeRemoteSourceLabel(candidate.SafeLabel)}
|
||||
detailJSON, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
@@ -336,7 +338,7 @@ func resolveSourceIngest(
|
||||
prepared.Candidate.Checksum,
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceID,
|
||||
prepared.Detail.Label,
|
||||
sourceDetailLabel(prepared.Detail),
|
||||
prepared.Candidate.Format,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -384,12 +386,14 @@ func commitSourceDeployment(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult upload.IngestResult,
|
||||
hasIngest bool,
|
||||
nextCheckNotBefore *time.Time,
|
||||
) (*model.PagesDeployment, bool, bool, error) {
|
||||
if snapshot == nil || manifest == nil {
|
||||
return nil, false, false, errors.New(errPagesSourceSyncFailed)
|
||||
@@ -403,7 +407,7 @@ func commitSourceDeployment(
|
||||
return err
|
||||
}
|
||||
target, targetReused, err := resolveSourceDeploymentTx(
|
||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
|
||||
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult, hasIngest,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -417,7 +421,7 @@ func commitSourceDeployment(
|
||||
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON); err != nil {
|
||||
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON, nextCheckNotBefore); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = *target
|
||||
@@ -491,6 +495,7 @@ func resolveSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
@@ -515,7 +520,7 @@ func resolveSourceDeploymentTx(
|
||||
return nil, false, errSourceFinalFence
|
||||
}
|
||||
return createSourceDeploymentTx(
|
||||
tx, state, revision, detail, detailJSON, actor, manifest, ingestResult,
|
||||
tx, state, revision, packageChecksum, detail, detailJSON, actor, manifest, ingestResult,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -523,6 +528,7 @@ func createSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
@@ -541,7 +547,7 @@ func createSourceDeploymentTx(
|
||||
target := &model.PagesDeployment{
|
||||
ProjectID: state.Project.ID,
|
||||
DeploymentNumber: maxNumber + 1,
|
||||
Checksum: revision,
|
||||
Checksum: packageChecksum,
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
UploadID: ingestResult.Upload.ID,
|
||||
FileCount: manifest.FileCount,
|
||||
@@ -550,7 +556,7 @@ func createSourceDeploymentTx(
|
||||
SourceType: state.Source.SourceType,
|
||||
SourceIdentity: &identity,
|
||||
SourceRevision: &revisionValue,
|
||||
SourceLabel: detail.Label,
|
||||
SourceLabel: sourceDetailLabel(detail),
|
||||
SourceMeta: detailJSON,
|
||||
TriggerType: pagesSourceTriggerManualSync,
|
||||
}
|
||||
@@ -629,6 +635,7 @@ func activateSourceDeploymentTx(
|
||||
target *model.PagesDeployment,
|
||||
revision string,
|
||||
detailJSON string,
|
||||
nextCheckNotBefore *time.Time,
|
||||
) error {
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", state.Project.ID).
|
||||
@@ -636,8 +643,8 @@ func activateSourceDeploymentTx(
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(target).Updates(map[string]any{
|
||||
"status": model.PagesDeploymentStatusActive,
|
||||
"activated_at": &state.Now,
|
||||
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
|
||||
"activated_at": &state.Now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -645,6 +652,15 @@ func activateSourceDeploymentTx(
|
||||
return err
|
||||
}
|
||||
finishedAt := sourceCommitNow()
|
||||
var nextCheckAt any
|
||||
if state.Source.SourceType == PagesSourceTypeGitHubRelease &&
|
||||
state.Source.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
next := nextGitHubCheckAt(finishedAt, state.Source.ID, state.Source.CheckIntervalMinutes)
|
||||
if nextCheckNotBefore != nil && nextCheckNotBefore.After(next) {
|
||||
next = nextCheckNotBefore.UTC()
|
||||
}
|
||||
nextCheckAt = &next
|
||||
}
|
||||
result := tx.Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ? AND lease_expires_at > ?",
|
||||
state.Runtime.SourceID,
|
||||
@@ -658,9 +674,9 @@ func activateSourceDeploymentTx(
|
||||
"last_applied_detail": detailJSON,
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
|
||||
sourceRuntimeColumnLastError: "",
|
||||
"last_checked_at": &finishedAt,
|
||||
sourceRuntimeColumnLastCheckedAt: &finishedAt,
|
||||
"last_synced_at": &finishedAt,
|
||||
"next_check_at": nil,
|
||||
"next_check_at": nextCheckAt,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
})
|
||||
|
||||
@@ -388,12 +388,14 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
|
||||
ctx,
|
||||
snapshot,
|
||||
deployment.Checksum,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: deployment.SourceLabel},
|
||||
deployment.Checksum,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: deployment.SourceLabel},
|
||||
deployment.SourceMeta,
|
||||
"user:5",
|
||||
&deploymentManifest{},
|
||||
upload.IngestResult{},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if !errors.Is(err, errSourceFinalFence) {
|
||||
t.Fatalf("commitSourceDeployment(expired after upload lock) error = %v, want %v", err, errSourceFinalFence)
|
||||
@@ -552,7 +554,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
||||
SourceIdentity: &identity,
|
||||
SourceRevision: &revision,
|
||||
SourceLabel: "deleted.zip",
|
||||
SourceMeta: `{"provider":"remote_url","label":"deleted.zip"}`,
|
||||
SourceMeta: `{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||
TriggerType: pagesSourceTriggerManualSync,
|
||||
}
|
||||
if err := db.DB(ctx).Create(deployment).Error; err != nil {
|
||||
@@ -575,12 +577,14 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
||||
ctx,
|
||||
snapshot,
|
||||
revision,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, Label: "deleted.zip"},
|
||||
`{"provider":"remote_url","label":"deleted.zip"}`,
|
||||
revision,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: "deleted.zip"},
|
||||
`{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||
"user:1",
|
||||
manifest,
|
||||
upload.IngestResult{},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if !errors.Is(err, errSourceFinalFence) {
|
||||
t.Errorf("commitSourceDeployment(deleted upload) error = %v, want %v", err, errSourceFinalFence)
|
||||
|
||||
@@ -77,7 +77,11 @@ func (h *SourceActionHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
(input.Action != sourceActionCheck && input.Action != sourceActionSync) ||
|
||||
!validPagesSourceActor(input.Actor) ||
|
||||
!validOptionalSourceRevision(input.TargetRevision) ||
|
||||
!validOptionalSourceRevision(input.ConfirmedRevision) {
|
||||
!validOptionalSourceRevision(input.ConfirmedRevision) ||
|
||||
(input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "")) ||
|
||||
(input.TargetRevision != "" && input.ConfirmedRevision != "") ||
|
||||
(input.TargetRevision != "" && input.Actor != pagesSourceCreatedBySystem) ||
|
||||
(input.ConfirmedRevision != "" && !strings.HasPrefix(input.Actor, "user:")) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
return json.Marshal(input)
|
||||
@@ -110,10 +114,13 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
||||
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
|
||||
}
|
||||
if source.SourceType != PagesSourceTypeRemoteURL {
|
||||
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if input.TargetRevision != "" || input.ConfirmedRevision != "" {
|
||||
if source.SourceType == PagesSourceTypeRemoteURL && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
if input.Action == sourceActionCheck && (input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
|
||||
@@ -132,10 +139,43 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
|
||||
result, err := syncRemoteSource(ctx, snapshot, input.Actor)
|
||||
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 = syncGitHubSource(ctx, snapshot, input.Actor, input.TargetRevision, input.ConfirmedRevision)
|
||||
} else {
|
||||
result, err = syncRemoteSource(ctx, snapshot, input.Actor)
|
||||
}
|
||||
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) {
|
||||
if isPermanentSourceSyncError(err) || shouldSkipGitHubActionRetry(err) {
|
||||
return nil, task.PermanentError(errPagesSourceSyncFailed)
|
||||
}
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
@@ -191,13 +231,19 @@ func isPermanentSourceSyncError(err error) bool {
|
||||
}
|
||||
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, errPagesSourceNetworkPolicy)
|
||||
strings.Contains(message, errPagesSourceNetworkPolicy) ||
|
||||
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.
|
||||
@@ -207,8 +253,20 @@ func DispatchSourceAction(
|
||||
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)
|
||||
@@ -224,14 +282,8 @@ func DispatchSourceAction(
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if source.SourceType != PagesSourceTypeRemoteURL {
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if action == sourceActionCheck {
|
||||
return nil, errors.New(errPagesSourceCheckUnsupported)
|
||||
}
|
||||
if confirmedRevision != "" {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
if err := validateSourceActionPreflight(ctx, &source, action, targetRevision, confirmedRevision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
busy, err := sourceLeaseIsBusy(ctx, source.ID)
|
||||
if err != nil {
|
||||
@@ -240,14 +292,58 @@ func DispatchSourceAction(
|
||||
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) {
|
||||
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,
|
||||
TargetRevision: "",
|
||||
TargetRevision: targetRevision,
|
||||
ConfirmedRevision: confirmedRevision,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -257,9 +353,9 @@ func DispatchSourceAction(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, "manual")
|
||||
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", projectID, source.ID, action, err)
|
||||
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 := model.GetTaskExecutionByTaskID(ctx, taskID)
|
||||
|
||||
@@ -30,6 +30,33 @@ func setupPagesSourceTest(t *testing.T) context.Context {
|
||||
return t.Context()
|
||||
}
|
||||
|
||||
func TestRevisionViewReadsLegacySourceDetailLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
detail string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "remote",
|
||||
detail: `{"provider":"remote_url","label":"legacy.zip"}`,
|
||||
want: "legacy.zip",
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
detail: `{"provider":"github","label":"v1.2.3","asset_name":"dist.zip"}`,
|
||||
want: "v1.2.3",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
view := revisionView(strings.Repeat("a", 64), test.detail)
|
||||
if view.Label != test.want {
|
||||
t.Errorf("revisionView(%s).Label = %q, want %q", test.name, view.Label, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustCreatePagesSourceProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
|
||||
t.Helper()
|
||||
view, err := CreateProject(ctx, Input{
|
||||
@@ -203,9 +230,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
Where("source_id = ?", source.ID).
|
||||
Updates(map[string]any{
|
||||
"last_seen_revision": seenRevision,
|
||||
"last_seen_detail": `{"provider":"remote_url","label":"new.zip"}`,
|
||||
"last_seen_detail": `{"provider":"remote_url","display_name":"new.zip"}`,
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": `{"provider":"remote_url","label":"old.zip"}`,
|
||||
"last_applied_detail": `{"provider":"remote_url","display_name":"old.zip"}`,
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"lease_token": "in-flight",
|
||||
"lease_expires_at": &future,
|
||||
|
||||
Reference in New Issue
Block a user