mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/share/pagesarchive"
|
||||
)
|
||||
|
||||
// downloadPagesPackageFromURL is the deprecated one-shot URL adapter. It uses
|
||||
// the same bounded downloader as persisted sources and allows insecure TLS for
|
||||
// operator-managed internal artifact services.
|
||||
func downloadPagesPackageFromURL(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
maxPackageBytes int64,
|
||||
) (tempPath string, checksum string, size int64, format pagesarchive.Format, fileName string, err error) {
|
||||
if _, err := parseAndValidatePagesDownloadURL(rawURL); err != nil {
|
||||
return "", "", 0, "", "", err
|
||||
}
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: strings.TrimSpace(rawURL),
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: maxPackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), errPagesSourceRemoteURLInvalid) {
|
||||
return "", "", 0, "", "", errors.New(errPagesPackageURLInvalid)
|
||||
}
|
||||
return "", "", 0, "", "", err
|
||||
}
|
||||
// Ownership transfers to the existing one-shot caller, which removes the
|
||||
// temporary file after the candidate deployment has been created.
|
||||
return candidate.TempPath, candidate.Checksum, candidate.PackageSize, candidate.Format, candidate.SafeLabel, nil
|
||||
}
|
||||
|
||||
func parseAndValidatePagesDownloadURL(raw string) (*url.URL, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil, errors.New(errPagesPackageURLRequired)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.User != nil || parsed.Fragment != "" || parsed.Opaque != "" {
|
||||
return nil, errors.New(errPagesPackageURLInvalid)
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
||||
if (scheme != remoteSourceSchemeHTTP && scheme != remoteSourceSchemeHTTPS) || strings.TrimSpace(parsed.Hostname()) == "" {
|
||||
return nil, errors.New(errPagesPackageURLInvalid)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseAndValidatePagesDownloadURL(t *testing.T) {
|
||||
_, err := parseAndValidatePagesDownloadURL("")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "填写")
|
||||
|
||||
_, err = parseAndValidatePagesDownloadURL("ftp://example.com/a.zip")
|
||||
require.Error(t, err)
|
||||
|
||||
// Private / local hosts are allowed (operator-controlled artifact hosts).
|
||||
for _, raw := range []string{
|
||||
"http://127.0.0.1/a.zip",
|
||||
"https://localhost/a.zip",
|
||||
"http://192.168.1.10:8080/site.tar.gz",
|
||||
"https://example.com/dist/site.tar.gz?x=1",
|
||||
} {
|
||||
parsed, parseErr := parseAndValidatePagesDownloadURL(raw)
|
||||
require.NoError(t, parseErr, raw)
|
||||
assert.NotEmpty(t, parsed.Hostname(), raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPagesPackageFromURLAllowsPrivateHost(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
zw := zip.NewWriter(&body)
|
||||
w, err := zw.Create("index.html")
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("ok"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
zipBytes := body.Bytes()
|
||||
|
||||
var sawProviderUA bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("User-Agent") == remoteSourceUserAgent {
|
||||
sawProviderUA = true
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="remote-site.zip"`)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(zipBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tempPath, checksum, size, format, fileName, err := downloadPagesPackageFromURL(
|
||||
context.Background(),
|
||||
server.URL+"/pkg.zip",
|
||||
10*1024*1024,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(tempPath) })
|
||||
assert.True(t, sawProviderUA)
|
||||
assert.NotEmpty(t, checksum)
|
||||
assert.Positive(t, size)
|
||||
assert.Equal(t, "zip", string(format))
|
||||
assert.Equal(t, "pkg.zip", fileName)
|
||||
}
|
||||
|
||||
func TestUploadDeploymentFromURLPrivateHost(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
var body bytes.Buffer
|
||||
zw := zip.NewWriter(&body)
|
||||
w, err := zw.Create("index.html")
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("from-url"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="from-url.zip"`)
|
||||
_, _ = w.Write(body.Bytes())
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "URL Site", Slug: "url-site", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
deployment, err := UploadDeploymentFromURL(ctx, project.ID, server.URL+"/from-url.zip", "root")
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, deployment.ID)
|
||||
assert.Equal(t, 1, deployment.FileCount)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package pages provides logics and management for OpenFlare static page deployments.
|
||||
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 = "pages 部署源类型不受支持"
|
||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||
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 部署源任务执行权已失效"
|
||||
errPagesSourceLeaseExpired = "上次 pages 部署源任务租约已过期"
|
||||
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
||||
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
||||
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
||||
)
|
||||
@@ -0,0 +1,339 @@
|
||||
// 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"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
"Wavelet/pkg/logger"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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
|
||||
AutoUpdate bool
|
||||
CheckInterval int
|
||||
SourceIdentity string
|
||||
}
|
||||
|
||||
func validateGitHubSourceInput(input SourceUpdateInput) error {
|
||||
if strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if strings.TrimSpace(input.RemoteURL) != "" || input.AllowInsecure {
|
||||
return errors.New(errPagesSourceGitHubFields)
|
||||
}
|
||||
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.AutoUpdateEnabled || 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
|
||||
}
|
||||
autoUpdate := input.AutoUpdateEnabled
|
||||
if selector == githubReleaseSelectorTag {
|
||||
autoUpdate = false
|
||||
interval = 0
|
||||
}
|
||||
return githubSourceConfig{
|
||||
Repository: repository,
|
||||
Selector: selector,
|
||||
Tag: tag,
|
||||
AssetName: assetName,
|
||||
AutoUpdate: autoUpdate,
|
||||
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) {
|
||||
if _, err := repository.LockPagesProjectByIDTx(tx, projectID); 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
|
||||
}
|
||||
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, existing.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
identityChanged := existing.SourceIdentity != config.SourceIdentity
|
||||
if err := repository.UpdatePagesProjectSourceTx(tx, existing, githubSourceUpdates(config, existing.ConfigVersion+1)); 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: config.AutoUpdate,
|
||||
CheckIntervalMinutes: config.CheckInterval,
|
||||
ConfigVersion: 1,
|
||||
SourceIdentity: config.SourceIdentity,
|
||||
}
|
||||
if err := repository.CreatePagesProjectSourceTx(tx, source); 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 repository.CreatePagesProjectSourceRuntimeTx(tx, runtime)
|
||||
}
|
||||
|
||||
func githubSourceUpdates(config githubSourceConfig, version int) map[string]any {
|
||||
return map[string]any{
|
||||
"source_type": PagesSourceTypeGitHubRelease,
|
||||
"remote_url": "",
|
||||
"allow_insecure": false,
|
||||
"github_repository": config.Repository,
|
||||
"release_selector": config.Selector,
|
||||
"release_tag": config.Tag,
|
||||
"asset_name": config.AssetName,
|
||||
sourceColumnAutoUpdateEnabled: config.AutoUpdate,
|
||||
"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.AllowInsecure || existing.GitHubRepository != config.Repository ||
|
||||
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
|
||||
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled != config.AutoUpdate ||
|
||||
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 repository.UpdatePagesProjectSourceRuntimeFieldTx(tx, runtime, "next_check_at", nextCheckAt)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
source, err := repository.GetPagesProjectSourceByIDAndConfigVersion(ctx, sourceID, configVersion)
|
||||
if 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()
|
||||
if _, err := repository.MarkPagesSourceInitialCheckDispatchFailed(ctx, sourceID, configVersion, now, updates); err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] mark initial check dispatch failure: source_id=%d error=%v", sourceID, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/githubrelease"
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
"Wavelet/openflare/plugins/server/kernel/task"
|
||||
"Wavelet/openflare/share/pagesarchive"
|
||||
"Wavelet/pkg/logger"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const githubSourceDetailProvider = "github"
|
||||
|
||||
var githubDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
type githubSourceProviderDomainError struct {
|
||||
message string
|
||||
permanent bool
|
||||
retryAt *time.Time
|
||||
statusCode int
|
||||
}
|
||||
|
||||
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
|
||||
Revision string
|
||||
Status string
|
||||
RetryAt *time.Time
|
||||
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 {
|
||||
revision, status, err := finishGitHubCheckNotModified(ctx, snapshot, result)
|
||||
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": revision, pagesDeploymentColumnStatus: status})
|
||||
return &githubCheckTaskResult{
|
||||
Message: "GitHub Release 检查完成,内容未变化",
|
||||
Detail: string(detail),
|
||||
Revision: revision,
|
||||
Status: status,
|
||||
RetryAt: result.RetryAt,
|
||||
}, 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), Revision: target.Revision,
|
||||
Status: status, RetryAt: result.RetryAt,
|
||||
}, 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,
|
||||
) (string, string, error) {
|
||||
var revision string
|
||||
var status string
|
||||
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
||||
runtime, now, err := lockOwnedSourceRuntime(tx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
revision = runtime.LastSeenRevision
|
||||
status = normalizedSourceRuntimeStatus(runtime)
|
||||
updates := githubCheckTerminalUpdates(snapshot, now, result.RetryAt)
|
||||
updates["etag"] = result.ETag
|
||||
updates[sourceRuntimeColumnSyncStatus] = status
|
||||
return repository.UpdatePagesProjectSourceRuntimeTx(tx, runtime, updates)
|
||||
})
|
||||
return revision, status, err
|
||||
}
|
||||
|
||||
func finishGitHubCheckTarget(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
result githubrelease.ResolveResult,
|
||||
target *githubSourceTarget,
|
||||
) (string, error) {
|
||||
status := pagesSourceStatusIdle
|
||||
err := repository.WithPagesTx(ctx, 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 repository.UpdatePagesProjectSourceRuntimeTx(tx, runtime, updates)
|
||||
})
|
||||
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[sourceRuntimeColumnNextCheckAt] = 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.In(now.Location())
|
||||
}
|
||||
return &next
|
||||
}
|
||||
|
||||
func lockOwnedSourceRuntime(
|
||||
tx *gorm.DB,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
) (*model.PagesProjectSourceRuntime, time.Time, error) {
|
||||
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, snapshot.SourceID)
|
||||
if 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.In(now.Location())
|
||||
}
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||
sourceRuntimeColumnLastError: safeSourceRuntimeError(message),
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
if snapshot.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
updates[sourceRuntimeColumnNextCheckAt] = &next
|
||||
} else {
|
||||
updates[sourceRuntimeColumnNextCheckAt] = nil
|
||||
}
|
||||
rows, err := repository.UpdatePagesSourceRuntimeByActiveLease(
|
||||
ctx, snapshot.SourceID, snapshot.LeaseToken, now, updates,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 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 {
|
||||
runtime, err := repository.GetPagesProjectSourceRuntimeBySourceID(ctx, sourceID)
|
||||
if 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 syncGitHubSourceWithTrigger(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
triggerType string,
|
||||
) (outcome *sourceSyncOutcome, resultErr error) {
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease || !validPagesSourceActor(actor) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if !validSourceDeploymentTrigger(triggerType) {
|
||||
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, triggerType, 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,
|
||||
triggerType string,
|
||||
prepared *preparedGitHubSource,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
|
||||
deployment, reused, referenced, err := commitSourceDeploymentWithTrigger(
|
||||
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
|
||||
prepared.target.Detail, prepared.target.DetailJSON, actor, triggerType, 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.In(now.Location())
|
||||
}
|
||||
nextCheckAt = &next
|
||||
}
|
||||
updates := map[string]any{
|
||||
"last_seen_revision": target.Revision,
|
||||
"last_seen_detail": target.DetailJSON,
|
||||
sourceRuntimeColumnSyncStatus: status,
|
||||
sourceRuntimeColumnLastError: lastError,
|
||||
sourceRuntimeColumnLastCheckedAt: &now,
|
||||
sourceRuntimeColumnNextCheckAt: nextCheckAt,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
}
|
||||
rows, err := repository.UpdatePagesSourceRuntimeByActiveLease(
|
||||
ctx, snapshot.SourceID, snapshot.LeaseToken, now, updates,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 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
|
||||
statusCode := 0
|
||||
var providerError *githubrelease.Error
|
||||
if errors.As(err, &providerError) {
|
||||
statusCode = providerError.StatusCode
|
||||
}
|
||||
retryAt, hasRetryAt := githubrelease.RetryAt(err)
|
||||
var retryDeadline *time.Time
|
||||
if hasRetryAt {
|
||||
retryDeadline = &retryAt
|
||||
}
|
||||
if err == nil {
|
||||
return &githubSourceProviderDomainError{message: message, permanent: false, statusCode: statusCode}
|
||||
}
|
||||
if githubrelease.IsDigestError(err) {
|
||||
message = errPagesSourceDigestMismatch
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true, statusCode: statusCode}
|
||||
}
|
||||
if githubrelease.IsNotFound(err) {
|
||||
message = errPagesSourceReleaseNotFound
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true, statusCode: statusCode}
|
||||
}
|
||||
if errors.Is(err, githubrelease.ErrAssetTooLarge) {
|
||||
message = errPagesPackageURLTooLarge
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true, statusCode: statusCode}
|
||||
}
|
||||
if errors.Is(err, githubrelease.ErrEmptyAsset) {
|
||||
message = errPagesPackageEmpty
|
||||
return &githubSourceProviderDomainError{message: message, permanent: true, statusCode: statusCode}
|
||||
}
|
||||
return &githubSourceProviderDomainError{
|
||||
message: message, permanent: !githubrelease.IsRetryable(err), retryAt: retryDeadline, statusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
)
|
||||
|
||||
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,896 @@
|
||||
// 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"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/githubrelease"
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/task"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
|
||||
"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 := repository.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,
|
||||
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
||||
AllowInsecure: false,
|
||||
}); 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.AllowInsecure {
|
||||
t.Errorf("GitHub switched source retained Remote fields: URL=%q allow_insecure=%v", github.RemoteURL, github.AllowInsecure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
// Isolate from other tests that may leave a global Asynq client registered.
|
||||
task.SetService(nil)
|
||||
t.Cleanup(func() { task.SetService(nil) })
|
||||
|
||||
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 TestGitHubSourceRejectsUnsafeOrModeIncompatibleFields(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", ReleaseSelector: "tag", ReleaseTag: "v1", CheckIntervalMinutes: 60},
|
||||
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", AutoUpdateEnabled: true},
|
||||
{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 := repository.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, TriggerType: pagesSourceTriggerManualSync},
|
||||
{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, TriggerType: pagesSourceTriggerScheduledAutoUpdate, TargetRevision: revision},
|
||||
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TriggerType: pagesSourceTriggerManualSync, 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 := repository.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
"Wavelet/openflare/share/pagesarchive"
|
||||
"Wavelet/pkg/logger"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
pagesMaxDeploymentFiles = 1000
|
||||
defaultPagesMaxPackageSizeMB = 100
|
||||
maxPagesMaxPackageSizeMB = 2048
|
||||
defaultPagesMaxHistoryCount = 20
|
||||
defaultPagesEntryFile = "index.html"
|
||||
defaultPagesFallbackPath = "/index.html"
|
||||
pagesIngestMarkerKey = "pages_ingest_marker"
|
||||
pagesIngestMarkerV2 = "pages_deployment_v2"
|
||||
pagesProjectIDMetadataKey = "pages_project_id"
|
||||
pagesSourceIDMetadataKey = "pages_source_id"
|
||||
pagesMaxPathLength = 512
|
||||
bytesPerMiB = 1024 * 1024
|
||||
pagesExtractedSizeMultiplier = 4
|
||||
pagesMinExtractedSizeBytes = 100 * bytesPerMiB
|
||||
pagesRowLockStrength = "UPDATE"
|
||||
)
|
||||
|
||||
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
|
||||
|
||||
type deploymentManifest struct {
|
||||
Files []model.PagesDeploymentFile
|
||||
FileCount int
|
||||
TotalSize int64
|
||||
EntryFile string
|
||||
Format pagesarchive.Format
|
||||
}
|
||||
|
||||
type pagesLimits struct {
|
||||
PackageBytes int64
|
||||
ExtractedBytes int64
|
||||
MaxFiles int
|
||||
HistoryCount int
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unique")
|
||||
}
|
||||
|
||||
func resolvePagesLimits(ctx context.Context) pagesLimits {
|
||||
packageMB := defaultPagesMaxPackageSizeMB
|
||||
if value, err := repository.GetIntByKey(ctx, model.ConfigKeyPagesMaxPackageSizeMB); err == nil && value > 0 {
|
||||
packageMB = value
|
||||
}
|
||||
if packageMB > maxPagesMaxPackageSizeMB {
|
||||
packageMB = maxPagesMaxPackageSizeMB
|
||||
}
|
||||
|
||||
historyCount := defaultPagesMaxHistoryCount
|
||||
if value, err := repository.GetIntByKey(ctx, model.ConfigKeyPagesMaxHistoryCount); err == nil {
|
||||
historyCount = max(value, 0)
|
||||
}
|
||||
|
||||
packageBytes := int64(packageMB) * bytesPerMiB
|
||||
extractedBytes := max(packageBytes*pagesExtractedSizeMultiplier, pagesMinExtractedSizeBytes)
|
||||
|
||||
return pagesLimits{
|
||||
PackageBytes: packageBytes,
|
||||
ExtractedBytes: extractedBytes,
|
||||
MaxFiles: pagesMaxDeploymentFiles,
|
||||
HistoryCount: historyCount,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePagesSlug(raw string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if valid {
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
builder.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "-")
|
||||
}
|
||||
|
||||
func validateAndNormalizePagesRootDir(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("pages 根目录长度不能超过 512")
|
||||
}
|
||||
normalized, err := pagesarchive.NormalizeLogicalPath(value, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pages 根目录不合法: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizePagesFallbackPath(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
value = defaultPagesFallbackPath
|
||||
}
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("spa fallback 回退路径长度不能超过 512")
|
||||
}
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
return "", errors.New("spa fallback 回退路径必须以 / 开头")
|
||||
}
|
||||
if value == "/" || strings.HasSuffix(value, "/") {
|
||||
return "", errors.New("spa fallback 回退路径必须指向具体文件")
|
||||
}
|
||||
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
|
||||
return "", errors.New("spa fallback 回退路径包含不支持的字符")
|
||||
}
|
||||
for _, r := range value {
|
||||
if r <= 0x20 || r == 0x7f {
|
||||
return "", errors.New("spa fallback 回退路径不能包含空白或控制字符")
|
||||
}
|
||||
}
|
||||
for segment := range strings.SplitSeq(value, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return "", errors.New("spa fallback 回退路径不能包含 . 或 .. 路径段")
|
||||
}
|
||||
}
|
||||
cleaned := path.Clean(value)
|
||||
if cleaned == "." || !strings.HasPrefix(cleaned, "/") {
|
||||
return "", errors.New("spa fallback 回退路径不合法")
|
||||
}
|
||||
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
|
||||
return "", errors.New("spa fallback 回退路径必须指向具体文件")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func normalizeStoredPagesFallbackPath(value string) string {
|
||||
normalized, err := normalizePagesFallbackPath(value)
|
||||
if err != nil {
|
||||
return defaultPagesFallbackPath
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validateAndNormalizePagesEntryFile(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
value = defaultPagesEntryFile
|
||||
}
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("pages 入口文件长度不能超过 512")
|
||||
}
|
||||
normalized, err := pagesarchive.NormalizeLogicalPath(value, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pages 入口文件不合法: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func persistPagesUploadTemp(fileHeader *multipart.FileHeader, maxPackageBytes int64) (string, string, int64, pagesarchive.Format, error) {
|
||||
format, ok := pagesarchive.DetectFormatFromName(fileHeader.Filename)
|
||||
if !ok {
|
||||
return "", "", 0, "", errors.New(errPagesPackageUnsupported)
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", "", 0, "", err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
temp, err := os.CreateTemp("", "openflare-pages-*."+safeTempSuffix(format))
|
||||
if err != nil {
|
||||
return "", "", 0, "", err
|
||||
}
|
||||
defer func() { _ = temp.Close() }()
|
||||
hash := sha256.New()
|
||||
limited := io.LimitReader(file, maxPackageBytes+1)
|
||||
written, err := io.Copy(io.MultiWriter(temp, hash), limited)
|
||||
if err != nil {
|
||||
_ = os.Remove(temp.Name())
|
||||
return "", "", 0, "", err
|
||||
}
|
||||
if written > maxPackageBytes {
|
||||
_ = os.Remove(temp.Name())
|
||||
return "", "", 0, "", fmt.Errorf("pages 部署包不能超过 %d MiB", maxPackageBytes/bytesPerMiB)
|
||||
}
|
||||
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, format, nil
|
||||
}
|
||||
|
||||
func safeTempSuffix(format pagesarchive.Format) string {
|
||||
switch format {
|
||||
case pagesarchive.FormatTarGz:
|
||||
return "tar.gz"
|
||||
case pagesarchive.FormatTarXz:
|
||||
return "tar.xz"
|
||||
case pagesarchive.FormatTarBz2:
|
||||
return "tar.bz2"
|
||||
case pagesarchive.FormatSevenZip:
|
||||
return "7z"
|
||||
case pagesarchive.FormatTar:
|
||||
return "tar"
|
||||
case pagesarchive.FormatZip:
|
||||
return "zip"
|
||||
default:
|
||||
return "zip"
|
||||
}
|
||||
}
|
||||
|
||||
func pagesLegacyRelativeCandidates(project *model.PagesProject, deployment *model.PagesDeployment) []string {
|
||||
if project == nil || deployment == nil {
|
||||
return nil
|
||||
}
|
||||
slug := strings.TrimSpace(project.Slug)
|
||||
checksum := strings.TrimSpace(deployment.Checksum)
|
||||
if slug == "" || checksum == "" {
|
||||
return nil
|
||||
}
|
||||
// Legacy artifacts were always stored as .zip.
|
||||
fileName := checksum + ".zip"
|
||||
return []string{
|
||||
filepath.Join("artifacts", slug, fileName),
|
||||
filepath.Join("pages", "artifacts", slug, fileName),
|
||||
filepath.Join("data", "pages", "artifacts", slug, fileName),
|
||||
}
|
||||
}
|
||||
|
||||
func ingestPagesDeploymentPackage(
|
||||
ctx context.Context,
|
||||
localPath string,
|
||||
checksum string,
|
||||
projectID uint,
|
||||
fileName string,
|
||||
format pagesarchive.Format,
|
||||
) (ofupload.IngestResult, error) {
|
||||
return ingestPagesDeploymentPackageWithSource(ctx, localPath, checksum, projectID, 0, fileName, format)
|
||||
}
|
||||
|
||||
func ingestPagesDeploymentPackageWithSource(
|
||||
ctx context.Context,
|
||||
localPath string,
|
||||
checksum string,
|
||||
projectID uint,
|
||||
sourceID uint,
|
||||
fileName string,
|
||||
format pagesarchive.Format,
|
||||
) (ofupload.IngestResult, error) {
|
||||
systemUser := repository.GetSystemUser(ctx)
|
||||
accessMode := 0
|
||||
extension := pagesarchive.NormalizeNameExtension(fileName, format)
|
||||
extra := map[string]any{
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: strconv.FormatUint(uint64(projectID), 10),
|
||||
}
|
||||
if sourceID != 0 {
|
||||
extra[pagesSourceIDMetadataKey] = strconv.FormatUint(uint64(sourceID), 10)
|
||||
}
|
||||
return ofupload.IngestFromLocalPath(ctx, localPath, ofupload.IngestRequest{
|
||||
UserID: systemUser.ID,
|
||||
FileName: fileName,
|
||||
MimeType: pagesarchive.MIMEType(format),
|
||||
Extension: extension,
|
||||
Hash: checksum,
|
||||
Type: ofupload.ReservedPagesDeploymentType,
|
||||
AccessMode: &accessMode,
|
||||
SkipExtensionCheck: true,
|
||||
Policy: ofupload.PolicyDedupNewRecord,
|
||||
Metadata: model.UploadMetadata{
|
||||
Extra: extra,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func removeDeploymentArtifact(ctx context.Context, projectID uint, deployment *model.PagesDeployment) {
|
||||
if deployment == nil {
|
||||
return
|
||||
}
|
||||
if deployment.UploadID == 0 {
|
||||
return
|
||||
}
|
||||
if err := removePagesUploadIfUnreferenced(ctx, projectID, deployment.UploadID); err != nil {
|
||||
logger.WarnF(ctx,
|
||||
"[Pages] remove deployment artifact failed: deployment_id=%d upload_id=%d error=%v",
|
||||
deployment.ID, deployment.UploadID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// removePagesUploadIfUnreferenced soft-deletes a reserved Pages upload only
|
||||
// after locking its project (when present), locking the upload, and rechecking
|
||||
// deployment references in the same transaction.
|
||||
func removePagesUploadIfUnreferenced(ctx context.Context, projectID uint, uploadID uint64) error {
|
||||
if uploadID == 0 {
|
||||
return nil
|
||||
}
|
||||
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
||||
if projectID != 0 {
|
||||
if _, projectErr := repository.LockPagesProjectByIDTx(tx, projectID); projectErr != nil &&
|
||||
!errors.Is(projectErr, gorm.ErrRecordNotFound) {
|
||||
return projectErr
|
||||
}
|
||||
}
|
||||
|
||||
var uploadRecord model.Upload
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("id = ?", uploadID).
|
||||
First(&uploadRecord).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if uploadRecord.Type != ofupload.ReservedPagesDeploymentType {
|
||||
return fmt.Errorf("pages 部署包上传类型不匹配: %s", uploadRecord.Type)
|
||||
}
|
||||
|
||||
var references int64
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("upload_id = ?", uploadID).
|
||||
Count(&references).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if references > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := ofupload.RemoveLockedTx(tx, &uploadRecord)
|
||||
return err
|
||||
})
|
||||
// Always invalidate after transaction completion, including idempotent no-op,
|
||||
// so a prior post-commit cache interruption can heal on retry.
|
||||
ofupload.InvalidateUploadMetaCache(ctx, uploadID)
|
||||
return err
|
||||
}
|
||||
|
||||
func inspectPagesPackage(packagePath string, format pagesarchive.Format, rootDir string, entryFile string, limits pagesLimits) (*deploymentManifest, error) {
|
||||
archiveManifest, err := pagesarchive.InspectFile(packagePath, format, pagesarchive.InspectOptions{
|
||||
RootDir: rootDir,
|
||||
EntryFile: entryFile,
|
||||
VerifySizes: true,
|
||||
Limits: pagesarchive.Limits{
|
||||
MaxFiles: limits.MaxFiles,
|
||||
MaxFileBytes: limits.ExtractedBytes,
|
||||
MaxTotalBytes: limits.ExtractedBytes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapPagesArchiveError(err)
|
||||
}
|
||||
manifest := &deploymentManifest{
|
||||
Files: make([]model.PagesDeploymentFile, 0, len(archiveManifest.Files)),
|
||||
FileCount: archiveManifest.FileCount,
|
||||
TotalSize: archiveManifest.TotalSize,
|
||||
EntryFile: entryFile,
|
||||
Format: format,
|
||||
}
|
||||
for _, file := range archiveManifest.Files {
|
||||
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
|
||||
Path: file.Path,
|
||||
Size: file.Size,
|
||||
Checksum: file.Checksum,
|
||||
})
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func mapPagesArchiveError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "unsupported pages package format"):
|
||||
return errors.New(errPagesPackageUnsupported)
|
||||
case strings.Contains(message, "open zip"), strings.Contains(message, "open gzip"),
|
||||
strings.Contains(message, "open xz"), strings.Contains(message, "open 7z"),
|
||||
strings.Contains(message, "read tar"):
|
||||
return errors.New(errPagesPackageInvalid)
|
||||
case strings.Contains(message, "empty"):
|
||||
return errors.New(errPagesPackageEmpty)
|
||||
case strings.Contains(message, "missing entry file"):
|
||||
return err
|
||||
case strings.Contains(message, "file count exceeds"):
|
||||
return fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
|
||||
case strings.Contains(message, "extracted size exceeds"):
|
||||
return errors.New(errPagesPackageExtractedTooLarge)
|
||||
case strings.Contains(message, "file too large"), strings.Contains(message, "size out of bounds"):
|
||||
return errors.New(errPagesPackageFileTooLarge)
|
||||
case strings.Contains(message, "symlink"):
|
||||
return err
|
||||
case strings.Contains(message, "absolute path"), strings.Contains(message, "escapes directory"):
|
||||
return err
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func packageDownloadName(deploymentID uint, fileName string, contentType string) string {
|
||||
if format, ok := pagesarchive.DetectFormatFromName(fileName); ok {
|
||||
return fmt.Sprintf("pages-deployment-%d.%s", deploymentID, pagesarchive.Extension(format))
|
||||
}
|
||||
// Fall back by content type.
|
||||
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||
case "application/gzip", "application/x-gzip":
|
||||
return fmt.Sprintf("pages-deployment-%d.tar.gz", deploymentID)
|
||||
case "application/x-xz":
|
||||
return fmt.Sprintf("pages-deployment-%d.tar.xz", deploymentID)
|
||||
case "application/x-bzip2":
|
||||
return fmt.Sprintf("pages-deployment-%d.tar.bz2", deploymentID)
|
||||
case "application/x-7z-compressed":
|
||||
return fmt.Sprintf("pages-deployment-%d.7z", deploymentID)
|
||||
case "application/x-tar":
|
||||
return fmt.Sprintf("pages-deployment-%d.tar", deploymentID)
|
||||
default:
|
||||
return fmt.Sprintf("pages-deployment-%d.zip", deploymentID)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,794 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
oftask "Wavelet/openflare/plugins/server/kernel/task"
|
||||
"Wavelet/openflare/plugins/server/kernel/testhelper"
|
||||
"Wavelet/pkg/idgen"
|
||||
uploadshared "Wavelet/plugins/domain/upload/shared"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupPagesTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sqliteDB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.Upload{},
|
||||
&model.UploadStat{},
|
||||
&model.TaskExecution{},
|
||||
&model.PagesProject{},
|
||||
&model.PagesDeployment{},
|
||||
&model.PagesDeploymentFile{},
|
||||
&model.PagesProjectSource{},
|
||||
&model.PagesProjectSourceRuntime{},
|
||||
&model.ConfigVersion{},
|
||||
&model.SystemConfig{},
|
||||
))
|
||||
require.NoError(t, sqliteDB.Create(&model.User{
|
||||
ID: 999,
|
||||
Username: "system",
|
||||
Password: "*",
|
||||
Nickname: "系统",
|
||||
IsActive: true,
|
||||
}).Error)
|
||||
require.NoError(t, sqliteDB.Create([]model.SystemConfig{
|
||||
{
|
||||
Key: model.ConfigKeyPagesMaxPackageSizeMB,
|
||||
Value: "100",
|
||||
Type: "business",
|
||||
Description: "Pages 部署包上传大小上限(MiB)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeyPagesMaxHistoryCount,
|
||||
Value: "0", // unlimited for existing tests
|
||||
Type: "business",
|
||||
Description: "Pages 每个项目最大历史部署保留数(0 表示不限制)",
|
||||
},
|
||||
}).Error)
|
||||
|
||||
db.SetDB(sqliteDB)
|
||||
require.NoError(t, idgen.Init(1))
|
||||
oftask.SetService(&testhelper.NoopTaskService{})
|
||||
mockStorage := uploadshared.NewMockStorageService()
|
||||
uploadshared.SetDBService(db.NewService(sqliteDB))
|
||||
uploadshared.SetStorageService(mockStorage)
|
||||
ofupload.SetStorage(mockStorage)
|
||||
_ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxPackageSizeMB)
|
||||
_ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxHistoryCount)
|
||||
return func() {
|
||||
ofupload.SetStorage(nil)
|
||||
uploadshared.ResetServices()
|
||||
db.SetDB(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func setupPagesStorageMock(t *testing.T) (restore func(), disable func()) {
|
||||
t.Helper()
|
||||
mock := uploadshared.NewMockStorageService()
|
||||
uploadshared.SetStorageService(mock)
|
||||
ofupload.SetStorage(mock)
|
||||
restore = func() {
|
||||
ofupload.SetStorage(nil)
|
||||
uploadshared.ResetServices()
|
||||
}
|
||||
disable = restore
|
||||
return restore, disable
|
||||
}
|
||||
|
||||
func TestCreateProject(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Marketing Site",
|
||||
Slug: "marketing-site",
|
||||
Description: "public site",
|
||||
Enabled: true,
|
||||
SPAFallbackEnabled: true,
|
||||
SPAFallbackPath: "/index.html",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, project.ID)
|
||||
assert.Equal(t, "Marketing Site", project.Name)
|
||||
assert.Equal(t, "marketing-site", project.Slug)
|
||||
assert.Equal(t, "public site", project.Description)
|
||||
assert.True(t, project.Enabled)
|
||||
assert.True(t, project.SPAFallbackEnabled)
|
||||
assert.Equal(t, "/index.html", project.SPAFallbackPath)
|
||||
assert.Equal(t, "index.html", project.EntryFile)
|
||||
assert.Equal(t, int64(0), project.DeploymentCount)
|
||||
|
||||
_, err = CreateProject(ctx, Input{
|
||||
Name: "Duplicate Slug",
|
||||
Slug: "marketing-site",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, errPagesSlugExists, err.Error())
|
||||
}
|
||||
|
||||
func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := CreateProject(ctx, Input{
|
||||
Name: "Unsafe Fallback",
|
||||
Slug: "unsafe-fallback",
|
||||
Enabled: true,
|
||||
SPAFallbackEnabled: true,
|
||||
SPAFallbackPath: "/index.html; proxy_pass http://evil",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "回退路径")
|
||||
}
|
||||
|
||||
func TestCreateProjectRejectsUnsafeContentPaths(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
rootDirs := []string{"/public", "public/../dist", "C:/public", `public\\dist`, "./public", "public\x00dist"}
|
||||
for index, rootDir := range rootDirs {
|
||||
_, err := CreateProject(ctx, Input{
|
||||
Name: fmt.Sprintf("Unsafe Root %d", index),
|
||||
Slug: fmt.Sprintf("unsafe-root-%d", index),
|
||||
RootDir: rootDir,
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.Error(t, err, rootDir)
|
||||
}
|
||||
|
||||
entryFiles := []string{"/index.html", "../index.html", "C:/index.html", `public\\index.html`, "./index.html", "index.html;bad"}
|
||||
for index, entryFile := range entryFiles {
|
||||
_, err := CreateProject(ctx, Input{
|
||||
Name: fmt.Sprintf("Unsafe Entry %d", index),
|
||||
Slug: fmt.Sprintf("unsafe-entry-%d", index),
|
||||
EntryFile: entryFile,
|
||||
})
|
||||
require.Error(t, err, entryFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProjectValidatesActiveDeploymentEntry(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Content Root",
|
||||
Slug: "content-root",
|
||||
Enabled: true,
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "root",
|
||||
"dist/index.html": "dist",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
staleCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "stale.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "stale",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
_, err = ActivateDeployment(ctx, project.ID, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := UpdateProject(ctx, project.ID, Input{
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Enabled: true,
|
||||
RootDir: "dist",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dist", updated.RootDir)
|
||||
|
||||
_, err = ActivateDeployment(ctx, project.ID, staleCandidate.ID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errPagesEntryFileMissing)
|
||||
|
||||
_, err = UpdateProject(ctx, project.ID, Input{
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Enabled: true,
|
||||
RootDir: "missing",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errPagesEntryFileMissing)
|
||||
|
||||
stored, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dist", stored.RootDir)
|
||||
assert.Equal(t, "index.html", stored.EntryFile)
|
||||
}
|
||||
|
||||
func TestUploadDeploymentAcceptsZeroByteFiles(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Zero Byte Site",
|
||||
Slug: "zero-byte-site",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "ok",
|
||||
".gitkeep": "",
|
||||
})), "root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, deployment.FileCount)
|
||||
}
|
||||
|
||||
func TestUploadDeploymentStoresPackageInUploadFramework(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Upload Framework Site",
|
||||
Slug: "upload-framework-site",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "ok",
|
||||
})), "root")
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, deployment.UploadID)
|
||||
|
||||
storedDeployment, err := repository.GetPagesDeploymentByID(ctx, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, storedDeployment.UploadID)
|
||||
assert.Empty(t, storedDeployment.ArtifactPath)
|
||||
|
||||
var uploadCount int64
|
||||
require.NoError(t, db.DB(ctx).Model(&model.Upload{}).Count(&uploadCount).Error)
|
||||
assert.Equal(t, int64(1), uploadCount)
|
||||
var uploadRecord model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&uploadRecord, storedDeployment.UploadID).Error)
|
||||
assert.Equal(t, ofupload.ReservedPagesDeploymentType, uploadRecord.Type)
|
||||
assert.Equal(t, pagesIngestMarkerV2, uploadRecord.Metadata.Extra[pagesIngestMarkerKey])
|
||||
assert.Equal(t, fmt.Sprint(project.ID), uploadRecord.Metadata.Extra[pagesProjectIDMetadataKey])
|
||||
assert.NotContains(t, uploadRecord.Metadata.Extra, "project_slug")
|
||||
assert.NotContains(t, uploadRecord.Metadata.Extra, "format")
|
||||
}
|
||||
|
||||
func TestOpenDeploymentPackageHydratesLegacyArtifactPath(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Legacy Site",
|
||||
Slug: "openspeedtest",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
artifactDir := filepath.Join(t.TempDir(), "pages", "artifacts", project.Slug)
|
||||
require.NoError(t, os.MkdirAll(artifactDir, 0o755))
|
||||
artifactPath := filepath.Join(artifactDir, "legacy-checksum.zip")
|
||||
require.NoError(t, os.WriteFile(artifactPath, testPagesZip(t, map[string]string{"index.html": "legacy"}), 0o644))
|
||||
|
||||
deployment := &model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
DeploymentNumber: 1,
|
||||
Checksum: "legacy-checksum",
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
ArtifactPath: artifactPath,
|
||||
FileCount: 1,
|
||||
TotalSize: 10,
|
||||
CreatedBy: "test",
|
||||
}
|
||||
require.NoError(t, db.DB(ctx).Create(deployment).Error)
|
||||
require.NoError(t, db.DB(ctx).Create(&model.PagesDeploymentFile{
|
||||
DeploymentID: deployment.ID,
|
||||
Path: "index.html",
|
||||
Size: 6,
|
||||
Checksum: "legacy-checksum",
|
||||
}).Error)
|
||||
|
||||
_, err = ActivateDeployment(ctx, project.ID, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
|
||||
Version: "v2026-legacy",
|
||||
SnapshotJSON: fmt.Sprintf(`{"routes":[{"upstream_type":"pages","pages_deployment":{"deployment_id":%d}}]}`, deployment.ID),
|
||||
MainConfig: "",
|
||||
RenderedConfig: "",
|
||||
SupportFilesJSON: "[]",
|
||||
Checksum: "legacy-config-checksum",
|
||||
IsActive: true,
|
||||
CreatedBy: "test",
|
||||
}).Error)
|
||||
|
||||
packageObj, err := OpenDeploymentPackage(ctx, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
defer packageObj.Body.Close()
|
||||
assert.Equal(t, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), packageObj.FileName)
|
||||
|
||||
body, err := io.ReadAll(packageObj.Body)
|
||||
require.NoError(t, err)
|
||||
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reader.File, 1)
|
||||
assert.Equal(t, "index.html", reader.File[0].Name)
|
||||
|
||||
storedDeployment, err := repository.GetPagesDeploymentByID(ctx, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, storedDeployment.UploadID)
|
||||
assert.Empty(t, storedDeployment.ArtifactPath)
|
||||
|
||||
var uploadCount int64
|
||||
require.NoError(t, db.DB(ctx).Model(&model.Upload{}).Count(&uploadCount).Error)
|
||||
assert.Equal(t, int64(1), uploadCount)
|
||||
|
||||
packageObj2, err := OpenDeploymentPackage(ctx, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
defer packageObj2.Body.Close()
|
||||
body2, err := io.ReadAll(packageObj2.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, body2)
|
||||
}
|
||||
|
||||
func TestOpenDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Published Site",
|
||||
Slug: "published-site",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "ok",
|
||||
})), "root")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ActivateDeployment(ctx, project.ID, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = OpenDeploymentPackage(ctx, deployment.ID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "激活配置")
|
||||
|
||||
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
|
||||
Version: "v2026-001",
|
||||
SnapshotJSON: fmt.Sprintf(`{"routes":[{"upstream_type":"pages","pages_deployment":{"deployment_id":%d}}]}`, deployment.ID),
|
||||
MainConfig: "",
|
||||
RenderedConfig: "",
|
||||
SupportFilesJSON: "[]",
|
||||
Checksum: "test-checksum",
|
||||
IsActive: true,
|
||||
CreatedBy: "test",
|
||||
}).Error)
|
||||
|
||||
packageObj, err := OpenDeploymentPackage(ctx, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
defer packageObj.Body.Close()
|
||||
assert.Equal(t, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), packageObj.FileName)
|
||||
|
||||
// Latest-by-project resolves the active package once the project is on active config.
|
||||
depID, hash, err := GetProjectLatestPackageHash(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, deployment.ID, depID)
|
||||
assert.NotEmpty(t, hash)
|
||||
|
||||
latestPkg, err := OpenProjectLatestPackage(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
defer latestPkg.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(packageObj.Body)
|
||||
require.NoError(t, err)
|
||||
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reader.File, 1)
|
||||
assert.Equal(t, "index.html", reader.File[0].Name)
|
||||
}
|
||||
|
||||
func TestProjectLatestRejectsWhenNotOnActiveConfigOrNotActive(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "Gate", Slug: "gate", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
d1, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "a.zip", testPagesZip(t, map[string]string{"index.html": "a"})), "root")
|
||||
require.NoError(t, err)
|
||||
d2, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "b.zip", testPagesZip(t, map[string]string{"index.html": "b"})), "root")
|
||||
require.NoError(t, err)
|
||||
_, err = ActivateDeployment(ctx, project.ID, d2.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// No active main config → reject.
|
||||
_, _, err = GetProjectLatestPackageHash(ctx, project.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
|
||||
Version: "v-gate",
|
||||
SnapshotJSON: fmt.Sprintf(
|
||||
`{"routes":[{"upstream_type":"pages","pages_project_id":%d,"pages_deployment":{"project_id":%d,"deployment_id":%d}}]}`,
|
||||
project.ID, project.ID, d2.ID,
|
||||
),
|
||||
SupportFilesJSON: "[]",
|
||||
Checksum: "c-gate",
|
||||
IsActive: true,
|
||||
CreatedBy: "test",
|
||||
}).Error)
|
||||
|
||||
// Non-active historical deployment must not be downloadable.
|
||||
_, err = OpenDeploymentPackage(ctx, d1.ID)
|
||||
require.Error(t, err)
|
||||
|
||||
// Active latest works.
|
||||
_, hash, err := GetProjectLatestPackageHash(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hash)
|
||||
|
||||
// Disabled project rejects.
|
||||
require.NoError(t, db.DB(ctx).Model(&model.PagesProject{}).Where("id = ?", project.ID).Update("enabled", false).Error)
|
||||
_, _, err = GetProjectLatestPackageHash(ctx, project.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func testPagesZip(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, content := range files {
|
||||
file, err := writer.Create(name)
|
||||
require.NoError(t, err)
|
||||
_, err = file.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func TestUploadDeploymentAcceptsTarGz(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "TarGz Site",
|
||||
Slug: "tar-gz-site",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.tar.gz", testPagesTarGz(t, map[string]string{
|
||||
"index.html": "tar-ok",
|
||||
"app.js": "1",
|
||||
})), "root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, deployment.FileCount)
|
||||
assert.NotZero(t, deployment.UploadID)
|
||||
}
|
||||
|
||||
func TestSelectDeploymentsToPruneKeepsActiveAndNewest(t *testing.T) {
|
||||
// ids 4(newest) ... 1(oldest); active is oldest id=1; keep=2 → keep {1,4}, prune {3,2}
|
||||
deployments := []model.PagesDeployment{
|
||||
{ID: 4, ProjectID: 1},
|
||||
{ID: 3, ProjectID: 1},
|
||||
{ID: 2, ProjectID: 1},
|
||||
{ID: 1, ProjectID: 1},
|
||||
}
|
||||
toDelete := selectDeploymentsToPrune(deployments, 1, 0, 2)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(3), toDelete[0].ID)
|
||||
assert.Equal(t, uint(2), toDelete[1].ID)
|
||||
|
||||
// active is newest; keep=2 → keep {4,3}, prune {2,1}
|
||||
toDelete = selectDeploymentsToPrune(deployments, 4, 0, 2)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(2), toDelete[0].ID)
|
||||
assert.Equal(t, uint(1), toDelete[1].ID)
|
||||
|
||||
// no active; keep=2 → keep {4,3}
|
||||
toDelete = selectDeploymentsToPrune(deployments, 0, 0, 2)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(2), toDelete[0].ID)
|
||||
assert.Equal(t, uint(1), toDelete[1].ID)
|
||||
|
||||
// keep=1 with active → only active, prune the rest
|
||||
toDelete = selectDeploymentsToPrune(deployments, 2, 0, 1)
|
||||
require.Len(t, toDelete, 3)
|
||||
for _, item := range toDelete {
|
||||
assert.NotEqual(t, uint(2), item.ID)
|
||||
}
|
||||
|
||||
// already within limit
|
||||
assert.Nil(t, selectDeploymentsToPrune(deployments[:2], 4, 0, 2))
|
||||
// unlimited
|
||||
assert.Nil(t, selectDeploymentsToPrune(deployments, 1, 0, 0))
|
||||
|
||||
// history=1 temporarily preserves active plus the freshly uploaded candidate.
|
||||
toDelete = selectDeploymentsToPrune(deployments, 2, 4, 1)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(3), toDelete[0].ID)
|
||||
assert.Equal(t, uint(1), toDelete[1].ID)
|
||||
assert.Equal(t, uint(4), resolveLatestCandidateID(deployments, 2, true))
|
||||
assert.Zero(t, resolveLatestCandidateID(deployments, 2, false))
|
||||
}
|
||||
|
||||
func TestPruneProjectDeploymentHistory(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, db.DB(ctx).Model(&model.SystemConfig{}).
|
||||
Where("key = ?", model.ConfigKeyPagesMaxHistoryCount).
|
||||
Update("value", "2").Error)
|
||||
require.NoError(t, repository.InvalidateSystemConfigCache(ctx, model.ConfigKeyPagesMaxHistoryCount))
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "History Site",
|
||||
Slug: "history-site",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var ids []uint
|
||||
for i := 0; i < 3; i++ {
|
||||
deployment, uploadErr := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": fmt.Sprintf("v%d", i),
|
||||
})), "root")
|
||||
require.NoError(t, uploadErr)
|
||||
ids = append(ids, deployment.ID)
|
||||
}
|
||||
// After 3 uploads with keep=2 and no active: only 2 newest remain.
|
||||
deployments, err := repository.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2)
|
||||
assert.Equal(t, ids[2], deployments[0].ID)
|
||||
assert.Equal(t, ids[1], deployments[1].ID)
|
||||
|
||||
// Activate the older of the remaining two, then upload again.
|
||||
_, err = ActivateDeployment(ctx, project.ID, ids[1])
|
||||
require.NoError(t, err)
|
||||
|
||||
latest, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v-latest",
|
||||
})), "root")
|
||||
require.NoError(t, err)
|
||||
|
||||
deployments, err = repository.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2, "must be at most N=2, not active+N newest")
|
||||
|
||||
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedProject.ActiveDeploymentID)
|
||||
assert.Equal(t, ids[1], *storedProject.ActiveDeploymentID)
|
||||
|
||||
kept := map[uint]struct{}{}
|
||||
for _, item := range deployments {
|
||||
kept[item.ID] = struct{}{}
|
||||
}
|
||||
_, hasActive := kept[ids[1]]
|
||||
_, hasLatest := kept[latest.ID]
|
||||
assert.True(t, hasActive, "active deployment must be retained")
|
||||
assert.True(t, hasLatest, "newest deployment must fill remaining slot")
|
||||
}
|
||||
|
||||
func TestHistoryCountOnePreservesFreshCandidateUntilActivation(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, db.DB(ctx).Model(&model.SystemConfig{}).
|
||||
Where("key = ?", model.ConfigKeyPagesMaxHistoryCount).
|
||||
Update("value", "1").Error)
|
||||
require.NoError(t, repository.InvalidateSystemConfigCache(ctx, model.ConfigKeyPagesMaxHistoryCount))
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "Single History", Slug: "single-history", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
active, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v1.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v1",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
_, err = ActivateDeployment(ctx, project.ID, active.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
oldCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v2.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v2",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
deployments, err := repository.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2)
|
||||
|
||||
newCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v3.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v3",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
deployments, err = repository.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2)
|
||||
kept := map[uint]bool{}
|
||||
for _, deployment := range deployments {
|
||||
kept[deployment.ID] = true
|
||||
}
|
||||
assert.True(t, kept[active.ID])
|
||||
assert.True(t, kept[newCandidate.ID])
|
||||
assert.False(t, kept[oldCandidate.ID])
|
||||
var removedUpload model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&removedUpload, oldCandidate.UploadID).Error)
|
||||
assert.Equal(t, model.UploadStatusDeleted, removedUpload.Status)
|
||||
|
||||
_, err = ActivateDeployment(ctx, project.ID, newCandidate.ID)
|
||||
require.NoError(t, err)
|
||||
deployments, err = repository.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 1)
|
||||
assert.Equal(t, newCandidate.ID, deployments[0].ID)
|
||||
}
|
||||
|
||||
func TestPruneUsesLockTimeNewestCandidateInsteadOfStaleCaller(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "Concurrent Candidate", Slug: "concurrent-candidate", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
active, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v1.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v1",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
_, err = ActivateDeployment(ctx, project.ID, active.ID)
|
||||
require.NoError(t, err)
|
||||
staleCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v2.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v2",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
newCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v3.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v3",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted, err := pruneProjectDeploymentHistoryOnce(ctx, project.ID, 1, staleCandidate.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, deleted)
|
||||
deployments, err := repository.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2)
|
||||
kept := map[uint]bool{}
|
||||
for _, deployment := range deployments {
|
||||
kept[deployment.ID] = true
|
||||
}
|
||||
assert.True(t, kept[active.ID])
|
||||
assert.True(t, kept[newCandidate.ID])
|
||||
assert.False(t, kept[staleCandidate.ID])
|
||||
}
|
||||
|
||||
func TestDeleteDeploymentAndProjectSoftDeleteUnreferencedArtifacts(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "Delete Artifacts", Slug: "delete-artifacts", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
first, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "first.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "first",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
second, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "second.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "second",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, DeleteDeployment(ctx, project.ID, second.ID))
|
||||
var secondUpload model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&secondUpload, second.UploadID).Error)
|
||||
assert.Equal(t, model.UploadStatusDeleted, secondUpload.Status)
|
||||
|
||||
require.NoError(t, DeleteProject(ctx, project.ID))
|
||||
var firstUpload model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&firstUpload, first.UploadID).Error)
|
||||
assert.Equal(t, model.UploadStatusDeleted, firstUpload.Status)
|
||||
_, err = repository.GetPagesProjectByID(ctx, project.ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func testPagesTarGz(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buffer)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
for name, content := range files {
|
||||
require.NoError(t, tarWriter.WriteHeader(&tar.Header{
|
||||
Name: name,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(content)),
|
||||
}))
|
||||
_, err := tarWriter.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, tarWriter.Close())
|
||||
require.NoError(t, gzWriter.Close())
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func testPagesMultipartFile(t *testing.T, fileName string, content []byte) *multipart.FileHeader {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("package", fileName)
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write(content)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
req := httptest.NewRequest("POST", "/", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
require.NoError(t, req.ParseMultipartForm(int64(len(content))+1024))
|
||||
|
||||
file, header, err := req.FormFile("package")
|
||||
require.NoError(t, err)
|
||||
file.Close()
|
||||
return header
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
)
|
||||
|
||||
// ProjectLatestPackageMetadata describes the active package limits published to Agents.
|
||||
type ProjectLatestPackageMetadata struct {
|
||||
DeploymentID uint
|
||||
Hash string
|
||||
PackageSize int64
|
||||
FileCount int
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
// GetProjectLatestPackageMetadata returns one coherent metadata snapshot for a
|
||||
// project's currently active deployment.
|
||||
func GetProjectLatestPackageMetadata(ctx context.Context, projectID uint) (*ProjectLatestPackageMetadata, error) {
|
||||
deployment, err := resolveProjectActiveDeploymentForAgent(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deployment.UploadID == 0 {
|
||||
if err := ensureDeploymentUploadRecord(ctx, deployment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deployment, err = repository.GetPagesDeploymentByID(ctx, deployment.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if deployment.UploadID == 0 {
|
||||
return nil, errors.New(errPagesDeploymentNotFound)
|
||||
}
|
||||
|
||||
uploadRecord, err := ofupload.GetActiveUpload(ctx, deployment.UploadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 部署包不存在: %w", err)
|
||||
}
|
||||
hash := strings.TrimSpace(uploadRecord.Hash)
|
||||
if hash == "" {
|
||||
hash = strings.TrimSpace(deployment.Checksum)
|
||||
}
|
||||
if hash == "" {
|
||||
return nil, errors.New(errPagesDeploymentHashMissing)
|
||||
}
|
||||
|
||||
return &ProjectLatestPackageMetadata{
|
||||
DeploymentID: deployment.ID,
|
||||
Hash: hash,
|
||||
PackageSize: uploadRecord.FileSize,
|
||||
FileCount: deployment.FileCount,
|
||||
TotalSize: deployment.TotalSize,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
)
|
||||
|
||||
func TestGetProjectLatestPackageMetadataPreservesZeroTotalSize(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
|
||||
ctx := context.Background()
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Empty Files",
|
||||
Slug: "empty-files",
|
||||
Enabled: true,
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
packageBytes := testPagesZip(t, map[string]string{
|
||||
"index.html": "",
|
||||
".gitkeep": "",
|
||||
})
|
||||
deployment, err := UploadDeployment(
|
||||
ctx,
|
||||
project.ID,
|
||||
testPagesMultipartFile(t, "empty-files.zip", packageBytes),
|
||||
"test",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDeployment() error = %v", err)
|
||||
}
|
||||
if _, err := ActivateDeployment(ctx, project.ID, deployment.ID); err != nil {
|
||||
t.Fatalf("ActivateDeployment() error = %v", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&model.ConfigVersion{
|
||||
Version: "v-package-metadata",
|
||||
SnapshotJSON: fmt.Sprintf(
|
||||
`{"routes":[{"upstream_type":"pages","pages_project_id":%d}]}`,
|
||||
project.ID,
|
||||
),
|
||||
SupportFilesJSON: "[]",
|
||||
Checksum: "package-metadata-config",
|
||||
IsActive: true,
|
||||
CreatedBy: "test",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create active ConfigVersion error = %v", err)
|
||||
}
|
||||
|
||||
got, err := GetProjectLatestPackageMetadata(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectLatestPackageMetadata(%d) error = %v", project.ID, err)
|
||||
}
|
||||
wantHashBytes := sha256.Sum256(packageBytes)
|
||||
wantHash := hex.EncodeToString(wantHashBytes[:])
|
||||
if got.DeploymentID != deployment.ID || got.Hash != wantHash {
|
||||
t.Errorf("GetProjectLatestPackageMetadata(%d) identity = (%d, %q), want (%d, %q)",
|
||||
project.ID, got.DeploymentID, got.Hash, deployment.ID, wantHash)
|
||||
}
|
||||
if got.PackageSize != int64(len(packageBytes)) {
|
||||
t.Errorf("GetProjectLatestPackageMetadata(%d).PackageSize = %d, want %d",
|
||||
project.ID, got.PackageSize, len(packageBytes))
|
||||
}
|
||||
if got.FileCount != 2 || got.TotalSize != 0 {
|
||||
t.Errorf("GetProjectLatestPackageMetadata(%d) content = (%d files, %d bytes), want (2 files, 0 bytes)",
|
||||
project.ID, got.FileCount, got.TotalSize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
openrestyrender "Wavelet/openflare/share/render/openresty"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RebindSnapshotPagesToCurrentActive rewrites pages_deployment fields so every
|
||||
// pages route points at the project's current active deployment.
|
||||
//
|
||||
// Main config versions and Pages deployments are independent. Rolling back a
|
||||
// main config version must not require old Pages packages; Agents always follow
|
||||
// the live active deployment for each referenced project.
|
||||
//
|
||||
// Returns the original JSON unchanged when there are no pages routes. Does not
|
||||
// mutate stored config_versions rows. Non-pages route fields are preserved.
|
||||
func RebindSnapshotPagesToCurrentActive(ctx context.Context, snapshotJSON string) (string, error) {
|
||||
text := strings.TrimSpace(snapshotJSON)
|
||||
if text == "" {
|
||||
return snapshotJSON, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(text, "[") {
|
||||
var routes []map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(text), &routes); err != nil {
|
||||
return "", fmt.Errorf("parse pages snapshot routes: %w", err)
|
||||
}
|
||||
changed, err := rebindPagesRouteMaps(ctx, routes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !changed {
|
||||
return snapshotJSON, nil
|
||||
}
|
||||
encoded, err := json.Marshal(routes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(text), &raw); err != nil {
|
||||
return "", fmt.Errorf("parse pages snapshot document: %w", err)
|
||||
}
|
||||
routesRaw, ok := raw["routes"]
|
||||
if !ok || len(routesRaw) == 0 {
|
||||
return snapshotJSON, nil
|
||||
}
|
||||
var routes []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(routesRaw, &routes); err != nil {
|
||||
return "", fmt.Errorf("parse pages snapshot routes: %w", err)
|
||||
}
|
||||
changed, err := rebindPagesRouteMaps(ctx, routes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !changed {
|
||||
return snapshotJSON, nil
|
||||
}
|
||||
encodedRoutes, err := json.Marshal(routes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw["routes"] = encodedRoutes
|
||||
encoded, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func rebindPagesRouteMaps(ctx context.Context, routes []map[string]json.RawMessage) (bool, error) {
|
||||
changed := false
|
||||
for index := range routes {
|
||||
route := routes[index]
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
upstreamType := rawJSONString(route["upstream_type"])
|
||||
if !strings.EqualFold(strings.TrimSpace(upstreamType), "pages") {
|
||||
continue
|
||||
}
|
||||
siteName := rawJSONString(route["site_name"])
|
||||
projectID, err := resolveProjectIDFromRouteMap(route)
|
||||
if err != nil {
|
||||
if siteName == "" {
|
||||
siteName = "pages"
|
||||
}
|
||||
return false, fmt.Errorf("路由 %s %w", siteName, err)
|
||||
}
|
||||
project, activeDeployment, err := loadActivePagesProject(ctx, projectID, siteName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
deployment, err := buildLivePagesDeployment(project, activeDeployment)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
projectIDCopy := project.ID
|
||||
originURL := fmt.Sprintf("openflare-pages://project/%d", project.ID)
|
||||
|
||||
if err := putJSON(route, "pages_project_id", projectIDCopy); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := putJSON(route, "pages_deployment", deployment); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := putJSON(route, "origin_url", originURL); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := putJSON(route, "upstreams", []string{originURL}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
routes[index] = route
|
||||
changed = true
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
const jsonNullLiteral = "null"
|
||||
|
||||
func isPresentJSON(raw json.RawMessage) bool {
|
||||
return len(raw) > 0 && string(raw) != jsonNullLiteral
|
||||
}
|
||||
|
||||
func resolveProjectIDFromRouteMap(route map[string]json.RawMessage) (uint, error) {
|
||||
if raw, ok := route["pages_project_id"]; ok && isPresentJSON(raw) {
|
||||
var projectID uint
|
||||
if err := json.Unmarshal(raw, &projectID); err == nil && projectID != 0 {
|
||||
return projectID, nil
|
||||
}
|
||||
}
|
||||
if raw, ok := route["pages_deployment"]; ok && isPresentJSON(raw) {
|
||||
var deployment struct {
|
||||
ProjectID uint `json:"project_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &deployment); err == nil && deployment.ProjectID != 0 {
|
||||
return deployment.ProjectID, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("pages 配置无效: 缺少 pages_project_id")
|
||||
}
|
||||
|
||||
func loadActivePagesProject(ctx context.Context, projectID uint, siteName string) (*model.PagesProject, *model.PagesDeployment, error) {
|
||||
if siteName == "" {
|
||||
siteName = "pages"
|
||||
}
|
||||
project, err := repository.GetPagesProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", siteName)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if !project.Enabled {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", siteName)
|
||||
}
|
||||
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", siteName)
|
||||
}
|
||||
activeDeployment, err := repository.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
|
||||
if err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", siteName)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if activeDeployment.ProjectID != project.ID {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", siteName)
|
||||
}
|
||||
if strings.TrimSpace(activeDeployment.Checksum) == "" {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", siteName)
|
||||
}
|
||||
return project, activeDeployment, nil
|
||||
}
|
||||
|
||||
func buildLivePagesDeployment(
|
||||
project *model.PagesProject,
|
||||
active *model.PagesDeployment,
|
||||
) (*openrestyrender.PagesDeployment, error) {
|
||||
rootDir, err := validateAndNormalizePagesRootDir(project.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(project.EntryFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
|
||||
if fallbackPath == "" {
|
||||
fallbackPath = defaultPagesFallbackPath
|
||||
}
|
||||
localRoot := openrestyrender.PagesProjectLocalRoot(project.ID)
|
||||
if rootDir != "" {
|
||||
localRoot = path.Join(localRoot, rootDir)
|
||||
}
|
||||
return &openrestyrender.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
ProjectSlug: strings.TrimSpace(project.Slug),
|
||||
DeploymentID: active.ID,
|
||||
DeploymentNumber: active.DeploymentNumber,
|
||||
Checksum: strings.TrimSpace(active.Checksum),
|
||||
EntryFile: entryFile,
|
||||
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
||||
SPAFallbackPath: fallbackPath,
|
||||
APIProxyEnabled: project.APIProxyEnabled,
|
||||
APIProxyPath: strings.TrimSpace(project.APIProxyPath),
|
||||
APIProxyPass: strings.TrimSpace(project.APIProxyPass),
|
||||
APIProxyRewrite: strings.TrimSpace(project.APIProxyRewrite),
|
||||
LocalRoot: localRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rawJSONString(raw json.RawMessage) string {
|
||||
if !isPresentJSON(raw) {
|
||||
return ""
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func putJSON(route map[string]json.RawMessage, key string, value any) error {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
route[key] = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorsIsNotFound(err error) bool {
|
||||
return err != nil && (errors.Is(err, gorm.ErrRecordNotFound) || strings.Contains(strings.ToLower(err.Error()), "record not found"))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRebindSnapshotPagesToCurrentActive(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Rebind Site",
|
||||
Slug: "rebind-site",
|
||||
Enabled: true,
|
||||
RootDir: "public/site",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
old := &model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
DeploymentNumber: 1,
|
||||
Checksum: "old-checksum",
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
FileCount: 1,
|
||||
}
|
||||
require.NoError(t, db.DB(ctx).Create(old).Error)
|
||||
active := &model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
DeploymentNumber: 2,
|
||||
Checksum: "new-checksum",
|
||||
Status: model.PagesDeploymentStatusActive,
|
||||
FileCount: 1,
|
||||
}
|
||||
require.NoError(t, db.DB(ctx).Create(active).Error)
|
||||
require.NoError(t, db.DB(ctx).Model(&model.PagesProject{}).
|
||||
Where("id = ?", project.ID).
|
||||
Update("active_deployment_id", active.ID).Error)
|
||||
|
||||
// Frozen snapshot still points at the old deployment (simulates old main config).
|
||||
frozen := map[string]any{
|
||||
"routes": []map[string]any{
|
||||
{
|
||||
"site_name": "rebind",
|
||||
"origin_url": "openflare-pages://project/1",
|
||||
"enabled": true,
|
||||
"upstream_type": "pages",
|
||||
"pages_project_id": project.ID,
|
||||
"pages_deployment": map[string]any{
|
||||
"project_id": project.ID,
|
||||
"deployment_id": old.ID,
|
||||
"checksum": "old-checksum",
|
||||
"local_root": "__OPENFLARE_PAGES_DIR__/projects/1/current",
|
||||
},
|
||||
"extra_keep_me": "yes",
|
||||
},
|
||||
},
|
||||
"waf": map[string]any{"rule_groups": []any{}},
|
||||
}
|
||||
frozenJSON, err := json.Marshal(frozen)
|
||||
require.NoError(t, err)
|
||||
|
||||
reboundJSON, err := RebindSnapshotPagesToCurrentActive(ctx, string(frozenJSON))
|
||||
require.NoError(t, err)
|
||||
|
||||
var rebound map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(reboundJSON), &rebound))
|
||||
_, hasWAF := rebound["waf"]
|
||||
assert.True(t, hasWAF)
|
||||
|
||||
routes := rebound["routes"].([]any)
|
||||
require.Len(t, routes, 1)
|
||||
route := routes[0].(map[string]any)
|
||||
assert.Equal(t, "yes", route["extra_keep_me"])
|
||||
deployment := route["pages_deployment"].(map[string]any)
|
||||
assert.EqualValues(t, active.ID, deployment["deployment_id"])
|
||||
assert.Equal(t, "new-checksum", deployment["checksum"])
|
||||
assert.Equal(t, "__OPENFLARE_PAGES_DIR__/projects/1/current/public/site", deployment["local_root"])
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/openflare/plugins/server/kernel/apiutil"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func handleLogicError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return apiutil.AbortNotFoundIfMissing(c, err, errPagesProjectNotFound)
|
||||
}
|
||||
|
||||
func handleSourceLogicError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == errPagesSourceNotFound {
|
||||
response.AbortNotFound(c, errPagesSourceNotFound)
|
||||
return true
|
||||
}
|
||||
switch err.Error() {
|
||||
case errPagesSourceActionBusy:
|
||||
response.AbortConflict(c, errPagesSourceActionBusy)
|
||||
case errPagesSourceTypeRequired,
|
||||
errPagesSourceTypeUnsupported,
|
||||
errPagesSourceRemoteFields,
|
||||
errPagesSourceRemoteURLRequired,
|
||||
errPagesSourceRemoteURLInvalid,
|
||||
errPagesSourceGitHubFields,
|
||||
errPagesSourceRepositoryInvalid,
|
||||
errPagesSourceSelectorInvalid,
|
||||
errPagesSourceAssetNameInvalid,
|
||||
errPagesSourceCheckInterval,
|
||||
errPagesSourceAutoNotAvailable,
|
||||
errPagesSourceReleaseNotFound,
|
||||
errPagesSourceDigestInvalid,
|
||||
errPagesSourceDigestMismatch,
|
||||
errPagesSourceConfirmationNeeded,
|
||||
errPagesSourceConfirmationStale,
|
||||
errPagesSourceCheckUnsupported,
|
||||
errPagesSourceActionInvalid:
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
case errPagesSourceTaskDispatchFailed:
|
||||
response.AbortInternal(c, errPagesSourceInternal)
|
||||
default:
|
||||
logger.ErrorF(c.Request.Context(), "[PagesSource] API operation failed: error=%v", err)
|
||||
response.AbortInternal(c, errPagesSourceInternal)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeStrictJSON(c *gin.Context, target any, allowEmpty bool) bool {
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
if allowEmpty && errors.Is(err, io.EOF) {
|
||||
return true
|
||||
}
|
||||
response.AbortBadRequest(c, errPagesSourceActionInvalid)
|
||||
return false
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
response.AbortBadRequest(c, errPagesSourceActionInvalid)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func deploymentIDParam(c *gin.Context) (uint, bool) {
|
||||
raw := c.Param("deployment_id")
|
||||
if raw == "" {
|
||||
response.AbortBadRequest(c, "无效的 ID")
|
||||
return 0, false
|
||||
}
|
||||
id64, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id64 == 0 {
|
||||
response.AbortBadRequest(c, "无效的 ID")
|
||||
return 0, false
|
||||
}
|
||||
return uint(id64), true
|
||||
}
|
||||
|
||||
func currentPagesActor(c *gin.Context) (string, bool) {
|
||||
if raw, ok := c.Get(contracts.AuthUserObjKey); ok {
|
||||
switch user := raw.(type) {
|
||||
case *contracts.UserDTO:
|
||||
if user != nil && user.ID != 0 {
|
||||
return fmt.Sprintf("user:%d", user.ID), true
|
||||
}
|
||||
case contracts.UserDTO:
|
||||
if user.ID != 0 {
|
||||
return fmt.Sprintf("user:%d", user.ID), true
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := c.Get(contracts.AuthUserIDKey); ok {
|
||||
switch id := raw.(type) {
|
||||
case uint64:
|
||||
if id != 0 {
|
||||
return fmt.Sprintf("user:%d", id), true
|
||||
}
|
||||
case int:
|
||||
if id > 0 {
|
||||
return fmt.Sprintf("user:%d", uint64(id)), true
|
||||
}
|
||||
}
|
||||
}
|
||||
response.AbortUnauthorized(c, errPagesActorMissing)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ListProjectsHandler 列出全部 Pages 项目。
|
||||
// @Summary 列出 Pages 项目
|
||||
// @Description 返回全部 OpenFlare Pages 项目,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} response.Any{data=[]pages.View} "Pages 项目列表"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages [get]
|
||||
func ListProjectsHandler(c *gin.Context) {
|
||||
projects, err := ListProjects(c.Request.Context())
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(projects))
|
||||
}
|
||||
|
||||
// GetProjectHandler 获取 Pages 项目详情。
|
||||
// @Summary 获取 Pages 项目详情
|
||||
// @Description 按 ID 返回 Pages 项目详情,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Success 200 {object} response.Any{data=pages.View} "Pages 项目详情"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id} [get]
|
||||
func GetProjectHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
project, err := GetProject(c.Request.Context(), id)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(project))
|
||||
}
|
||||
|
||||
// CreateProjectHandler 创建 Pages 项目。
|
||||
// @Summary 创建 Pages 项目
|
||||
// @Description 创建新的 OpenFlare Pages 项目,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param request body pages.Input true "项目参数"
|
||||
// @Success 200 {object} response.Any{data=pages.View} "创建成功的项目"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages [post]
|
||||
func CreateProjectHandler(c *gin.Context) {
|
||||
var input Input
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
project, err := CreateProject(c.Request.Context(), input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(project))
|
||||
}
|
||||
|
||||
// UpdateProjectHandler 更新 Pages 项目。
|
||||
// @Summary 更新 Pages 项目
|
||||
// @Description 按 ID 更新 OpenFlare Pages 项目,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Param request body pages.Input true "项目参数"
|
||||
// @Success 200 {object} response.Any{data=pages.View} "更新后的项目"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/update [post]
|
||||
func UpdateProjectHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
project, err := UpdateProject(c.Request.Context(), id, input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(project))
|
||||
}
|
||||
|
||||
// DeleteProjectHandler 删除 Pages 项目。
|
||||
// @Summary 删除 Pages 项目
|
||||
// @Description 按 ID 删除 OpenFlare Pages 项目,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Success 200 {object} response.Any "删除成功"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/delete [post]
|
||||
func DeleteProjectHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := DeleteProject(c.Request.Context(), id); handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
// GetSourceHandler 获取 Pages 项目的部署源。
|
||||
// @Summary 获取 Pages 部署源
|
||||
// @Description 返回项目部署源配置与运行状态,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Success 200 {object} response.Any{data=pages.SourceView} "部署源"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "项目或部署源不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/source [get]
|
||||
func GetSourceHandler(c *gin.Context) {
|
||||
projectID, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
source, err := GetSource(c.Request.Context(), projectID)
|
||||
if handleSourceLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(source))
|
||||
}
|
||||
|
||||
// UpdateSourceHandler 创建或更新 Pages 项目部署源。
|
||||
// @Summary 更新 Pages 部署源
|
||||
// @Description 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
|
||||
// @Tags openflare-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Param request body pages.SourceUpdateInput true "部署源配置"
|
||||
// @Success 200 {object} response.Any{data=pages.SourceUpdateResult} "更新结果"
|
||||
// @Failure 400 {object} response.Any "配置无效"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/source/update [post]
|
||||
func UpdateSourceHandler(c *gin.Context) {
|
||||
projectID, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input SourceUpdateInput
|
||||
if !decodeStrictJSON(c, &input, false) {
|
||||
return
|
||||
}
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := UpdateSourceAs(c.Request.Context(), projectID, input, actor)
|
||||
if handleSourceLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(result))
|
||||
}
|
||||
|
||||
// DeleteSourceHandler 将 Pages 项目切换回手动部署模式。
|
||||
// @Summary 删除 Pages 部署源
|
||||
// @Description 幂等删除持久部署源;已有部署历史与当前生产部署保持不变
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Success 200 {object} response.Any{data=pages.SourceView} "手动来源视图"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/source/delete [post]
|
||||
func DeleteSourceHandler(c *gin.Context) {
|
||||
projectID, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
source, err := DeleteSource(c.Request.Context(), projectID)
|
||||
if handleSourceLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(source))
|
||||
}
|
||||
|
||||
// CheckSourceHandler 请求检查 Pages 部署源。
|
||||
// @Summary 检查 Pages 部署源
|
||||
// @Description 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Success 200 {object} response.Any{data=pages.SourceActionReceipt} "任务回执"
|
||||
// @Failure 400 {object} response.Any "当前来源不支持检查"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "部署源不存在"
|
||||
// @Failure 409 {object} response.Any "来源任务正在执行"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/source/check [post]
|
||||
func CheckSourceHandler(c *gin.Context) {
|
||||
projectID, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
receipt, err := DispatchSourceAction(c.Request.Context(), projectID, sourceActionCheck, actor, "")
|
||||
if handleSourceLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(receipt))
|
||||
}
|
||||
|
||||
// SourceSyncInput is the optional source sync action payload.
|
||||
type SourceSyncInput struct {
|
||||
ConfirmedRevision string `json:"confirmed_revision"`
|
||||
}
|
||||
|
||||
// SyncSourceHandler 请求同步并发布 Pages 部署源。
|
||||
// @Summary 同步并发布 Pages 部署源
|
||||
// @Description 异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效
|
||||
// @Tags openflare-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Param request body pages.SourceSyncInput false "同步参数"
|
||||
// @Success 200 {object} response.Any{data=pages.SourceActionReceipt} "任务回执"
|
||||
// @Failure 400 {object} response.Any "参数或来源类型无效"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "部署源不存在"
|
||||
// @Failure 409 {object} response.Any "来源任务正在执行"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/source/sync [post]
|
||||
func SyncSourceHandler(c *gin.Context) {
|
||||
projectID, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input SourceSyncInput
|
||||
if !decodeStrictJSON(c, &input, true) {
|
||||
return
|
||||
}
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
receipt, err := DispatchSourceAction(
|
||||
c.Request.Context(),
|
||||
projectID,
|
||||
sourceActionSync,
|
||||
actor,
|
||||
input.ConfirmedRevision,
|
||||
)
|
||||
if handleSourceLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(receipt))
|
||||
}
|
||||
|
||||
// ListDeploymentsHandler 列出项目的全部部署。
|
||||
// @Summary 列出 Pages 部署
|
||||
// @Description 返回指定项目的全部部署记录,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Success 200 {object} response.Any{data=[]pages.DeploymentView} "部署列表"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/deployments [get]
|
||||
func ListDeploymentsHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deployments, err := ListProjectDeployments(c.Request.Context(), id)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(deployments))
|
||||
}
|
||||
|
||||
// UploadDeploymentHandler 上传 Pages 部署包。
|
||||
// @Summary 上传 Pages 部署包
|
||||
// @Description 为指定项目上传静态资源压缩包(zip/tar.gz/tar.xz/tar.bz2/tar/7z),需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Param package formData file true "部署包文件"
|
||||
// @Success 200 {object} response.Any{data=pages.DeploymentView} "部署记录"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/deployments/upload [post]
|
||||
func UploadDeploymentHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("package")
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, errPagesPackageMissing)
|
||||
return
|
||||
}
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deployment, err := UploadDeployment(c.Request.Context(), id, file, actor)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(deployment))
|
||||
}
|
||||
|
||||
// UploadDeploymentFromURLHandler 从 URL 下载并创建 Pages 部署。
|
||||
// @Summary 从 URL 导入 Pages 部署包
|
||||
// @Description 已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源
|
||||
// @Deprecated
|
||||
// @Tags openflare-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Param request body pages.UploadFromURLInput true "下载链接"
|
||||
// @Success 200 {object} response.Any{data=pages.DeploymentView} "部署记录"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/deployments/upload-from-url [post]
|
||||
func UploadDeploymentFromURLHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UploadFromURLInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, errPagesPackageURLRequired)
|
||||
return
|
||||
}
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deployment, err := UploadDeploymentFromURL(c.Request.Context(), id, req.URL, actor)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(deployment))
|
||||
}
|
||||
|
||||
// ActivateDeploymentHandler 激活 Pages 部署。
|
||||
// @Summary 激活 Pages 部署
|
||||
// @Description 将指定部署设为项目当前生效版本,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Param deployment_id path int true "部署 ID"
|
||||
// @Success 200 {object} response.Any{data=pages.View} "激活后的项目"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目或部署不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/deployments/{deployment_id}/activate [post]
|
||||
func ActivateDeploymentHandler(c *gin.Context) {
|
||||
projectID, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deploymentID, ok := deploymentIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
project, err := ActivateDeploymentAs(c.Request.Context(), projectID, deploymentID, actor)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(project))
|
||||
}
|
||||
|
||||
// DeleteDeploymentHandler 删除 Pages 部署。
|
||||
// @Summary 删除 Pages 部署
|
||||
// @Description 删除指定项目的部署记录,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "项目 ID"
|
||||
// @Param deployment_id path int true "部署 ID"
|
||||
// @Success 200 {object} response.Any "删除成功"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "项目或部署不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/{id}/deployments/{deployment_id}/delete [post]
|
||||
func DeleteDeploymentHandler(c *gin.Context) {
|
||||
projectID, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deploymentID, ok := deploymentIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := DeleteDeployment(c.Request.Context(), projectID, deploymentID); handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
// ListDeploymentFilesHandler 列出部署文件清单。
|
||||
// @Summary 列出 Pages 部署文件
|
||||
// @Description 返回指定部署包含的文件清单,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param deployment_id path int true "部署 ID"
|
||||
// @Success 200 {object} response.Any{data=[]pages.DeploymentFileView} "部署文件列表"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 404 {object} response.Any "部署不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/pages/deployments/{deployment_id}/files [get]
|
||||
func ListDeploymentFilesHandler(c *gin.Context) {
|
||||
deploymentID, ok := deploymentIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
files, err := ListDeploymentFiles(c.Request.Context(), deploymentID)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(files))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/testhelper"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type sourceHandlerEnvelope struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func newPagesSourceTestRouter(userID uint64) *gin.Engine {
|
||||
router := testhelper.NewTestGinEngine(func(ctx *gin.Context) {
|
||||
ctx.Set(contracts.AuthUserIDKey, userID)
|
||||
ctx.Next()
|
||||
})
|
||||
router.GET("/api/v1/d/pages/:id/source", GetSourceHandler)
|
||||
router.POST("/api/v1/d/pages/:id/source/update", UpdateSourceHandler)
|
||||
router.POST("/api/v1/d/pages/:id/source/delete", DeleteSourceHandler)
|
||||
router.POST("/api/v1/d/pages/:id/source/check", CheckSourceHandler)
|
||||
router.POST("/api/v1/d/pages/:id/source/sync", SyncSourceHandler)
|
||||
return router
|
||||
}
|
||||
|
||||
func performPagesSourceRequest(
|
||||
t *testing.T,
|
||||
router http.Handler,
|
||||
method string,
|
||||
path string,
|
||||
body []byte,
|
||||
) (int, sourceHandlerEnvelope) {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
var envelope sourceHandlerEnvelope
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("json.Unmarshal(%s %s response %q) error = %v, want nil", method, path, recorder.Body.String(), err)
|
||||
}
|
||||
return recorder.Code, envelope
|
||||
}
|
||||
|
||||
func setupPagesSourceDispatchTest(t *testing.T) {
|
||||
t.Helper()
|
||||
}
|
||||
|
||||
func TestPagesSourceHandlersReturnStableActionErrors(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
router := newPagesSourceTestRouter(42)
|
||||
|
||||
manualProject := mustCreatePagesSourceProject(t, ctx, "handler-no-source")
|
||||
code, envelope := performPagesSourceRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("/api/v1/d/pages/%d/source/sync", manualProject.ID),
|
||||
nil,
|
||||
)
|
||||
if got, want := code, http.StatusNotFound; got != want {
|
||||
t.Errorf("POST source/sync without source status = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := envelope.ErrorMsg, errPagesSourceNotFound; got != want {
|
||||
t.Errorf("POST source/sync without source error = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
remoteProject := mustCreatePagesSourceProject(t, ctx, "handler-check")
|
||||
_, _ = mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
remoteProject.ID,
|
||||
"https://example.com/site.zip?token=handler-secret",
|
||||
false,
|
||||
)
|
||||
code, envelope = performPagesSourceRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("/api/v1/d/pages/%d/source/check", remoteProject.ID),
|
||||
nil,
|
||||
)
|
||||
if got, want := code, http.StatusBadRequest; got != want {
|
||||
t.Errorf("POST remote source/check status = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := envelope.ErrorMsg, errPagesSourceCheckUnsupported; got != want {
|
||||
t.Errorf("POST remote source/check error = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(string(envelope.Data), "handler-secret") || strings.Contains(envelope.ErrorMsg, "handler-secret") {
|
||||
t.Errorf("POST remote source/check response = %+v, want no URL secret", envelope)
|
||||
}
|
||||
|
||||
busyProject := mustCreatePagesSourceProject(t, ctx, "handler-busy")
|
||||
busySource, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
busyProject.ID,
|
||||
"https://example.com/site.zip",
|
||||
false,
|
||||
)
|
||||
future := time.Now().Add(time.Minute)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", busySource.ID).
|
||||
Updates(map[string]any{
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"lease_token": "busy-owner",
|
||||
"lease_expires_at": &future,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed busy source runtime error = %v, want nil", err)
|
||||
}
|
||||
code, envelope = performPagesSourceRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("/api/v1/d/pages/%d/source/sync", busyProject.ID),
|
||||
[]byte(`{}`),
|
||||
)
|
||||
if got, want := code, http.StatusConflict; got != want {
|
||||
t.Errorf("POST busy source/sync status = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := envelope.ErrorMsg, errPagesSourceActionBusy; got != want {
|
||||
t.Errorf("POST busy source/sync error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSourceHandlerAcceptsEmptyBodyAndEmptyObject(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
setupPagesSourceDispatchTest(t)
|
||||
router := newPagesSourceTestRouter(77)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "handler-empty-sync")
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=dispatch-secret",
|
||||
false,
|
||||
)
|
||||
path := fmt.Sprintf("/api/v1/d/pages/%d/source/sync", project.ID)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{name: "empty body", body: nil},
|
||||
{name: "empty object", body: []byte(`{}`)},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
code, envelope := performPagesSourceRequest(t, router, http.MethodPost, path, test.body)
|
||||
if got, want := code, http.StatusOK; got != want {
|
||||
t.Fatalf("POST source/sync (%s) status = %d, want %d; error=%q", test.name, got, want, envelope.ErrorMsg)
|
||||
}
|
||||
if envelope.ErrorMsg != "" {
|
||||
t.Errorf("POST source/sync (%s) error = %q, want empty", test.name, envelope.ErrorMsg)
|
||||
}
|
||||
var receipt SourceActionReceipt
|
||||
if err := json.Unmarshal(envelope.Data, &receipt); err != nil {
|
||||
t.Fatalf("json.Unmarshal(source/sync %s receipt) error = %v, want nil", test.name, err)
|
||||
}
|
||||
if receipt.TaskID == "" || receipt.ExecutionID == "" || receipt.Action != sourceActionSync {
|
||||
t.Errorf("POST source/sync (%s) receipt = %+v, want task/execution IDs and action %q", test.name, receipt, sourceActionSync)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var executions []model.TaskExecution
|
||||
if err := db.DB(ctx).Where("task_type = ?", TaskTypePagesSourceAction).Order("id asc").Find(&executions).Error; err != nil {
|
||||
t.Fatalf("list Pages source task executions error = %v, want nil", err)
|
||||
}
|
||||
if got, want := len(executions), 2; got != want {
|
||||
t.Fatalf("Pages source task execution count = %d, want %d", got, want)
|
||||
}
|
||||
for _, execution := range executions {
|
||||
if strings.Contains(execution.Payload, "dispatch-secret") || strings.Contains(execution.Payload, "http") {
|
||||
t.Errorf("task execution %q payload = %s, want no Remote URL secret", execution.TaskID, execution.Payload)
|
||||
}
|
||||
var payload SourceActionPayload
|
||||
if err := json.Unmarshal([]byte(execution.Payload), &payload); err != nil {
|
||||
t.Errorf("json.Unmarshal(task execution %q payload) error = %v, want nil", execution.TaskID, err)
|
||||
continue
|
||||
}
|
||||
if payload.SourceID != source.ID || payload.ConfigVersion != source.ConfigVersion || payload.Actor != "user:77" {
|
||||
t.Errorf("task execution %q payload = %+v, want source=%d config=%d actor=user:77", execution.TaskID, payload, source.ID, source.ConfigVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUploadDeploymentHandlerRecordsCurrentUserActor(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
|
||||
project, err := CreateProject(t.Context(), Input{Name: "Actor Upload", Slug: "actor-upload", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "ok"})
|
||||
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
part, err := writer.CreateFormFile("package", "site.zip")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write(packageBytes)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/d/pages/1/deployments/upload", &requestBody)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.FormatUint(uint64(project.ID), 10)}}
|
||||
c.Set(contracts.AuthUserIDKey, uint64(42))
|
||||
|
||||
UploadDeploymentHandler(c)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
deployments, err := repository.ListPagesDeployments(t.Context(), project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 1)
|
||||
assert.Equal(t, "user:42", deployments[0].CreatedBy)
|
||||
}
|
||||
|
||||
func TestUploadDeploymentFromURLHandlerRecordsCurrentUserActor(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
|
||||
project, err := CreateProject(t.Context(), Input{Name: "Actor URL", Slug: "actor-url", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "ok"})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="site.zip"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(packageBytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body, err := json.Marshal(UploadFromURLInput{URL: server.URL + "/site.zip"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/d/pages/1/deployments/upload-from-url", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(project.ID)}}
|
||||
c.Set(contracts.AuthUserIDKey, uint64(77))
|
||||
|
||||
UploadDeploymentFromURLHandler(c)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
deployments, err := repository.ListPagesDeployments(t.Context(), project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 1)
|
||||
assert.Equal(t, "user:77", deployments[0].CreatedBy)
|
||||
}
|
||||
|
||||
func TestCurrentPagesActorRejectsMissingUser(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
actor, ok := currentPagesActor(c)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, actor)
|
||||
assert.True(t, c.IsAborted())
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PagesSourceTypeManual represents projects without a persisted source row.
|
||||
PagesSourceTypeManual = "manual"
|
||||
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
|
||||
PagesSourceTypeRemoteURL = "remote_url"
|
||||
// PagesSourceTypeGitHubRelease represents a public GitHub Release asset.
|
||||
PagesSourceTypeGitHubRelease = "github_release"
|
||||
|
||||
pagesSourceStatusIdle = "idle"
|
||||
pagesSourceStatusChecking = "checking"
|
||||
pagesSourceStatusUpdateAvailable = "update_available"
|
||||
pagesSourceStatusSyncing = "syncing"
|
||||
pagesSourceStatusFailed = "failed"
|
||||
pagesSourceStatusAttention = "attention"
|
||||
|
||||
defaultRemoteAssetLabel = "pages-package"
|
||||
defaultGitHubAssetName = "dist.zip"
|
||||
defaultCheckInterval = 1440
|
||||
minimumCheckInterval = 5
|
||||
maximumCheckInterval = 1440
|
||||
)
|
||||
|
||||
// SourceUpdateInput is the discriminated source configuration payload.
|
||||
// GitHub fields are accepted by the decoder so mode-incompatible values can be
|
||||
// rejected deterministically.
|
||||
type SourceUpdateInput struct {
|
||||
SourceType string `json:"source_type"`
|
||||
RemoteURL string `json:"remote_url"`
|
||||
AllowInsecure bool `json:"allow_insecure"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
ReleaseSelector string `json:"release_selector"`
|
||||
ReleaseTag string `json:"release_tag"`
|
||||
AssetName string `json:"asset_name"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
||||
}
|
||||
|
||||
// SourceRevisionView is a source cursor shown to the console.
|
||||
type SourceRevisionView struct {
|
||||
Revision string `json:"revision"`
|
||||
Label string `json:"label"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
}
|
||||
|
||||
// SourceView is the discriminated source view returned to the console.
|
||||
type SourceView struct {
|
||||
SourceType string `json:"source_type"`
|
||||
RemoteURL string `json:"remote_url,omitempty"`
|
||||
AllowInsecure bool `json:"allow_insecure,omitempty"`
|
||||
GitHubRepository string `json:"github_repository,omitempty"`
|
||||
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"`
|
||||
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
|
||||
SyncStatus string `json:"sync_status,omitempty"`
|
||||
UpdateAvailable bool `json:"update_available,omitempty"`
|
||||
LastSeen *SourceRevisionView `json:"last_seen,omitempty"`
|
||||
LastApplied *SourceRevisionView `json:"last_applied,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
|
||||
NextCheckAt *time.Time `json:"next_check_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// SourceActionReceipt identifies the internal task execution created by an action API.
|
||||
type SourceActionReceipt struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// SourceUpdateResult is returned after persisting source configuration.
|
||||
type SourceUpdateResult struct {
|
||||
Source *SourceView `json:"source"`
|
||||
CheckTask *SourceActionReceipt `json:"check_task"`
|
||||
Warning string `json:"warning"`
|
||||
}
|
||||
|
||||
type sourceDetail struct {
|
||||
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 {
|
||||
URL string
|
||||
AllowInsecure bool
|
||||
Identity string
|
||||
}
|
||||
|
||||
// GetSource returns the current persisted source or a manual discriminator.
|
||||
func GetSource(ctx context.Context, projectID uint) (*SourceView, error) {
|
||||
if _, err := repository.GetPagesProjectByID(ctx, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, runtime, err := loadSourceByProject(ctx, projectID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &SourceView{SourceType: PagesSourceTypeManual}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSourceView(source, runtime)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
||||
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
|
||||
}
|
||||
source, loadErr := repository.LockPagesProjectSourceByProjectIDTx(tx, projectID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
persistedSource = *source
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view, err := GetSource(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) (bool, error) {
|
||||
if _, err := repository.LockPagesProjectByIDTx(tx, projectID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hasExisting {
|
||||
return true, createRemoteSourceTx(tx, projectID, config)
|
||||
}
|
||||
return updateExistingRemoteSourceTx(tx, existing, config)
|
||||
}
|
||||
|
||||
func loadProjectSourceForUpdate(tx *gorm.DB, projectID uint) (*model.PagesProjectSource, bool, error) {
|
||||
source, err := repository.LockPagesProjectSourceByProjectIDTx(tx, projectID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &model.PagesProjectSource{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return source, true, nil
|
||||
}
|
||||
|
||||
func buildRemoteSourceConfig(
|
||||
_ *model.PagesProjectSource,
|
||||
_ bool,
|
||||
input SourceUpdateInput,
|
||||
) (remoteSourceConfig, error) {
|
||||
remoteURL := strings.TrimSpace(input.RemoteURL)
|
||||
if remoteURL == "" {
|
||||
return remoteSourceConfig{}, errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
parsedURL, err := parseRemoteSourceURL(remoteURL)
|
||||
if err != nil {
|
||||
return remoteSourceConfig{}, err
|
||||
}
|
||||
return remoteSourceConfig{
|
||||
URL: remoteURL,
|
||||
AllowInsecure: input.AllowInsecure,
|
||||
Identity: remoteSourceIdentity(parsedURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createRemoteSourceTx(tx *gorm.DB, projectID uint, config remoteSourceConfig) error {
|
||||
source := &model.PagesProjectSource{
|
||||
ProjectID: projectID,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: config.URL,
|
||||
AllowInsecure: config.AllowInsecure,
|
||||
AutoUpdateEnabled: false,
|
||||
CheckIntervalMinutes: 0,
|
||||
ConfigVersion: 1,
|
||||
SourceIdentity: config.Identity,
|
||||
}
|
||||
if err := repository.CreatePagesProjectSourceTx(tx, source); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.CreatePagesProjectSourceRuntimeTx(tx, &model.PagesProjectSourceRuntime{
|
||||
SourceID: source.ID,
|
||||
SyncStatus: pagesSourceStatusIdle,
|
||||
})
|
||||
}
|
||||
|
||||
func updateExistingRemoteSourceTx(
|
||||
tx *gorm.DB,
|
||||
existing *model.PagesProjectSource,
|
||||
config remoteSourceConfig,
|
||||
) (bool, error) {
|
||||
if !remoteSourceConfigChanged(existing, config) {
|
||||
return false, nil
|
||||
}
|
||||
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, existing.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
identityChanged := existing.SourceIdentity != config.Identity
|
||||
if err := repository.UpdatePagesProjectSourceTx(tx, existing, map[string]any{
|
||||
"source_type": PagesSourceTypeRemoteURL,
|
||||
"remote_url": config.URL,
|
||||
"allow_insecure": config.AllowInsecure,
|
||||
"github_repository": "",
|
||||
"release_selector": "",
|
||||
"release_tag": "",
|
||||
"asset_name": "",
|
||||
sourceColumnAutoUpdateEnabled: false,
|
||||
"check_interval_minutes": 0,
|
||||
sourceColumnConfigVersion: existing.ConfigVersion + 1,
|
||||
"source_identity": config.Identity,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, resetRuntimeAfterSourceUpdate(tx, runtime, identityChanged)
|
||||
}
|
||||
|
||||
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
||||
return existing.SourceType != PagesSourceTypeRemoteURL ||
|
||||
existing.RemoteURL != config.URL ||
|
||||
existing.AllowInsecure != config.AllowInsecure ||
|
||||
existing.GitHubRepository != "" ||
|
||||
existing.ReleaseSelector != "" ||
|
||||
existing.ReleaseTag != "" ||
|
||||
existing.AssetName != "" ||
|
||||
existing.AutoUpdateEnabled ||
|
||||
existing.CheckIntervalMinutes != 0
|
||||
}
|
||||
|
||||
// DeleteSource idempotently switches a project back to manual mode.
|
||||
func DeleteSource(ctx context.Context, projectID uint) (*SourceView, error) {
|
||||
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
||||
if _, err := repository.LockPagesProjectByIDTx(tx, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := repository.LockPagesProjectSourceByProjectIDTx(tx, projectID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, source.ID); err != nil &&
|
||||
!errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := repository.DeletePagesProjectSourceRuntimeBySourceIDTx(tx, source.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.DeletePagesProjectSourceTx(tx, source)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SourceView{SourceType: PagesSourceTypeManual}, nil
|
||||
}
|
||||
|
||||
func validateRemoteSourceInput(input SourceUpdateInput) error {
|
||||
sourceType := strings.TrimSpace(input.SourceType)
|
||||
if sourceType == "" {
|
||||
return errors.New(errPagesSourceTypeRequired)
|
||||
}
|
||||
if sourceType != PagesSourceTypeRemoteURL {
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if strings.TrimSpace(input.RepositoryURL) != "" || strings.TrimSpace(input.ReleaseSelector) != "" ||
|
||||
strings.TrimSpace(input.ReleaseTag) != "" || strings.TrimSpace(input.AssetName) != "" ||
|
||||
input.AutoUpdateEnabled || input.CheckIntervalMinutes != 0 {
|
||||
return errors.New(errPagesSourceRemoteFields)
|
||||
}
|
||||
if strings.TrimSpace(input.RemoteURL) == "" {
|
||||
return errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
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 parseRemoteSourceURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
return nil, errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
if parsed.Scheme != remoteSourceSchemeHTTP && parsed.Scheme != remoteSourceSchemeHTTPS {
|
||||
return nil, errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
if strings.TrimSpace(parsed.Hostname()) == "" {
|
||||
return nil, errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func remoteSourceIdentity(parsed *url.URL) string {
|
||||
hostname := strings.ToLower(parsed.Hostname())
|
||||
port := parsed.Port()
|
||||
if (parsed.Scheme == "https" && port == "443") || (parsed.Scheme == "http" && port == "80") {
|
||||
port = ""
|
||||
}
|
||||
host := hostname
|
||||
if port != "" {
|
||||
host = net.JoinHostPort(hostname, port)
|
||||
} else if strings.Contains(hostname, ":") {
|
||||
host = "[" + hostname + "]"
|
||||
}
|
||||
canonicalPath := parsed.EscapedPath()
|
||||
if canonicalPath == "" {
|
||||
canonicalPath = "/"
|
||||
}
|
||||
canonicalPath = path.Clean("/" + strings.TrimPrefix(canonicalPath, "/"))
|
||||
canonical := parsed.Scheme + "://" + host + canonicalPath
|
||||
sum := sha256.Sum256([]byte("remote_url|" + canonical))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func loadSourceByProject(ctx context.Context, projectID uint) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime, error) {
|
||||
return repository.GetPagesProjectSourceAndRuntimeByProjectID(ctx, projectID)
|
||||
}
|
||||
|
||||
func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProjectSourceRuntime) (*SourceView, error) {
|
||||
if source == nil || runtime == nil {
|
||||
return nil, errors.New(errPagesSourceNotFound)
|
||||
}
|
||||
view := &SourceView{
|
||||
SourceType: source.SourceType,
|
||||
SyncStatus: runtime.SyncStatus,
|
||||
LastSyncedAt: runtime.LastSyncedAt,
|
||||
LastError: runtime.LastError,
|
||||
}
|
||||
if runtime.LastAppliedRevision != "" {
|
||||
view.LastApplied = revisionView(runtime.LastAppliedRevision, runtime.LastAppliedDetail)
|
||||
}
|
||||
switch source.SourceType {
|
||||
case PagesSourceTypeRemoteURL:
|
||||
view.RemoteURL = source.RemoteURL
|
||||
view.AllowInsecure = source.AllowInsecure
|
||||
case PagesSourceTypeGitHubRelease:
|
||||
view.LastCheckedAt = runtime.LastCheckedAt
|
||||
view.NextCheckAt = runtime.NextCheckAt
|
||||
view.UpdateAvailable = runtime.LastSeenRevision != "" && runtime.LastSeenRevision != runtime.LastAppliedRevision
|
||||
if runtime.LastSeenRevision != "" {
|
||||
view.LastSeen = revisionView(runtime.LastSeenRevision, runtime.LastSeenDetail)
|
||||
}
|
||||
view.GitHubRepository = source.GitHubRepository
|
||||
view.ReleaseSelector = source.ReleaseSelector
|
||||
view.ReleaseTag = source.ReleaseTag
|
||||
view.AssetName = source.AssetName
|
||||
autoUpdateEnabled := source.AutoUpdateEnabled
|
||||
view.AutoUpdateEnabled = &autoUpdateEnabled
|
||||
view.CheckIntervalMinutes = source.CheckIntervalMinutes
|
||||
default:
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
||||
detail := sourceDetail{}
|
||||
_ = unmarshalSourceDetail(detailJSON, &detail)
|
||||
label := sourceDetailLabel(detail)
|
||||
if label == "" {
|
||||
label = defaultRemoteAssetLabel
|
||||
}
|
||||
return &SourceRevisionView{
|
||||
Revision: revision,
|
||||
Label: label,
|
||||
AssetName: detail.AssetName,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return json.Unmarshal([]byte(raw), detail)
|
||||
}
|
||||
|
||||
func resetRuntimeAfterSourceUpdate(tx *gorm.DB, runtime *model.PagesProjectSourceRuntime, identityChanged bool) error {
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
sourceRuntimeColumnLastError: "",
|
||||
}
|
||||
if identityChanged {
|
||||
updates["etag"] = ""
|
||||
updates["last_seen_revision"] = ""
|
||||
updates["last_seen_detail"] = ""
|
||||
updates["last_applied_revision"] = ""
|
||||
updates["last_applied_detail"] = ""
|
||||
updates["last_checked_at"] = nil
|
||||
updates["last_synced_at"] = nil
|
||||
updates["next_check_at"] = nil
|
||||
updates[sourceRuntimeColumnSyncStatus] = pagesSourceStatusIdle
|
||||
} else {
|
||||
updates[sourceRuntimeColumnSyncStatus] = normalizedSourceRuntimeStatus(runtime)
|
||||
}
|
||||
return repository.UpdatePagesProjectSourceRuntimeTx(tx, runtime, updates)
|
||||
}
|
||||
|
||||
func normalizedSourceRuntimeStatus(runtime *model.PagesProjectSourceRuntime) string {
|
||||
if runtime == nil {
|
||||
return pagesSourceStatusIdle
|
||||
}
|
||||
if sourceHasSameReleaseReplacement(runtime) {
|
||||
return pagesSourceStatusAttention
|
||||
}
|
||||
if runtime.LastSeenRevision != "" && runtime.LastSeenRevision != runtime.LastAppliedRevision {
|
||||
return pagesSourceStatusUpdateAvailable
|
||||
}
|
||||
return pagesSourceStatusIdle
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
)
|
||||
|
||||
func syncRemoteSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
return syncRemoteSourceWithTrigger(ctx, snapshot, actor, pagesSourceTriggerManualSync)
|
||||
}
|
||||
|
||||
func syncGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
return syncGitHubSourceWithTrigger(
|
||||
ctx, snapshot, actor, targetRevision, confirmedRevision, pagesSourceTriggerManualSync,
|
||||
)
|
||||
}
|
||||
|
||||
func commitSourceDeployment(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult ofupload.IngestResult,
|
||||
hasIngest bool,
|
||||
nextCheckNotBefore *time.Time,
|
||||
) (*model.PagesDeployment, bool, bool, error) {
|
||||
return commitSourceDeploymentWithTrigger(
|
||||
ctx, snapshot, revision, packageChecksum, detail, detailJSON, actor,
|
||||
pagesSourceTriggerManualSync, manifest, ingestResult, hasIngest, nextCheckNotBefore,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
"Wavelet/pkg/logger"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const pagesOrphanUploadIsolation = 2 * time.Hour
|
||||
|
||||
// PagesOrphanCleanupSummary describes one bounded delayed compensation pass.
|
||||
// Every candidate is counted exactly once in one outcome field.
|
||||
//
|
||||
//nolint:revive // Keep the domain-qualified exported name for scanner/task result clarity.
|
||||
type PagesOrphanCleanupSummary struct {
|
||||
Candidates int `json:"candidates"`
|
||||
Reconciled int `json:"reconciled"`
|
||||
Referenced int `json:"referenced"`
|
||||
LeaseBusy int `json:"lease_busy"`
|
||||
InvalidMarker int `json:"invalid_marker"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type pagesOrphanMarker struct {
|
||||
ProjectID uint
|
||||
SourceID *uint
|
||||
}
|
||||
|
||||
type pagesOrphanCleanupOutcome uint8
|
||||
|
||||
const (
|
||||
pagesOrphanCleanupSkipped pagesOrphanCleanupOutcome = iota
|
||||
pagesOrphanCleanupReconciled
|
||||
pagesOrphanCleanupReferenced
|
||||
pagesOrphanCleanupLeaseBusy
|
||||
pagesOrphanCleanupInvalidMarker
|
||||
)
|
||||
|
||||
// ReconcilePagesOrphanUploads performs one bounded delayed compensation pass.
|
||||
// Individual candidate failures are counted and logged so they do not prevent
|
||||
// the scanner from continuing with source checks.
|
||||
func ReconcilePagesOrphanUploads(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
) (PagesOrphanCleanupSummary, error) {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
cutoff := now.UTC().Add(-pagesOrphanUploadIsolation)
|
||||
systemUser := repository.GetSystemUser(ctx)
|
||||
candidates, err := repository.ListPagesOrphanUploadCandidates(ctx, model.PagesOrphanUploadCandidateQuery{
|
||||
SystemUserID: systemUser.ID,
|
||||
UploadType: ofupload.ReservedPagesDeploymentType,
|
||||
Marker: pagesIngestMarkerV2,
|
||||
CreatedBefore: cutoff,
|
||||
})
|
||||
if err != nil {
|
||||
return PagesOrphanCleanupSummary{}, err
|
||||
}
|
||||
|
||||
summary := PagesOrphanCleanupSummary{Candidates: len(candidates)}
|
||||
for index := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
candidate := &candidates[index]
|
||||
marker, err := parsePagesOrphanMarker(candidate.Metadata)
|
||||
if err != nil {
|
||||
summary.InvalidMarker++
|
||||
logger.WarnF(ctx, "[PagesSource] orphan upload marker invalid: upload_id=%d error=%v", candidate.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
outcome, err := reconcilePagesOrphanUploadCandidate(
|
||||
ctx,
|
||||
candidate,
|
||||
marker,
|
||||
systemUser.ID,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
summary.Failed++
|
||||
logger.WarnF(ctx, "[PagesSource] orphan upload reconciliation failed: upload_id=%d error=%v", candidate.ID, err)
|
||||
continue
|
||||
}
|
||||
summary.add(outcome)
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (summary *PagesOrphanCleanupSummary) add(outcome pagesOrphanCleanupOutcome) {
|
||||
switch outcome {
|
||||
case pagesOrphanCleanupReconciled:
|
||||
summary.Reconciled++
|
||||
case pagesOrphanCleanupReferenced:
|
||||
summary.Referenced++
|
||||
case pagesOrphanCleanupLeaseBusy:
|
||||
summary.LeaseBusy++
|
||||
case pagesOrphanCleanupInvalidMarker:
|
||||
summary.InvalidMarker++
|
||||
case pagesOrphanCleanupSkipped:
|
||||
summary.Skipped++
|
||||
default:
|
||||
summary.Skipped++
|
||||
}
|
||||
}
|
||||
|
||||
func reconcilePagesOrphanUploadCandidate(
|
||||
ctx context.Context,
|
||||
candidate *model.Upload,
|
||||
marker pagesOrphanMarker,
|
||||
systemUserID uint64,
|
||||
cutoff time.Time,
|
||||
) (pagesOrphanCleanupOutcome, error) {
|
||||
if candidate == nil || candidate.ID == 0 {
|
||||
return pagesOrphanCleanupSkipped, nil
|
||||
}
|
||||
|
||||
outcome := pagesOrphanCleanupSkipped
|
||||
uploadLocked := false
|
||||
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
||||
scopeOutcome, proceed, err := lockPagesOrphanCleanupScope(ctx, tx, candidate.ID, marker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !proceed {
|
||||
outcome = scopeOutcome
|
||||
return nil
|
||||
}
|
||||
lockedOutcome, locked, err := reconcileLockedPagesOrphanUpload(
|
||||
ctx,
|
||||
tx,
|
||||
candidate.ID,
|
||||
marker,
|
||||
systemUserID,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outcome = lockedOutcome
|
||||
uploadLocked = locked
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return pagesOrphanCleanupSkipped, err
|
||||
}
|
||||
if uploadLocked {
|
||||
// Also heal a prior post-commit cache invalidation interruption when the
|
||||
// status transition was an idempotent no-op.
|
||||
ofupload.InvalidateUploadMetaCache(ctx, candidate.ID)
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func lockPagesOrphanCleanupScope(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
uploadID uint64,
|
||||
marker pagesOrphanMarker,
|
||||
) (pagesOrphanCleanupOutcome, bool, error) {
|
||||
var project model.PagesProject
|
||||
if _, err := lockOptionalPagesCleanupRecord(tx, &project, "id = ?", marker.ProjectID); err != nil {
|
||||
return pagesOrphanCleanupSkipped, false, err
|
||||
}
|
||||
if marker.SourceID == nil {
|
||||
return pagesOrphanCleanupSkipped, true, nil
|
||||
}
|
||||
|
||||
source, err := repository.LockPagesProjectSourceByIDTx(tx, *marker.SourceID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return pagesOrphanCleanupSkipped, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return pagesOrphanCleanupSkipped, false, err
|
||||
}
|
||||
if source.ProjectID != marker.ProjectID {
|
||||
logger.WarnF(ctx,
|
||||
"[PagesSource] orphan upload source ownership mismatch: upload_id=%d project_id=%d source_id=%d source_project_id=%d",
|
||||
uploadID,
|
||||
marker.ProjectID,
|
||||
*marker.SourceID,
|
||||
source.ProjectID,
|
||||
)
|
||||
return pagesOrphanCleanupInvalidMarker, false, nil
|
||||
}
|
||||
|
||||
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, source.ID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return pagesOrphanCleanupSkipped, false, err
|
||||
}
|
||||
// Read the real clock only after obtaining the runtime row lock. The scanner
|
||||
// snapshot time is only an isolation cutoff and may be stale after lock wait.
|
||||
leaseCheckedAt := time.Now()
|
||||
if err == nil && runtime.LeaseExpiresAt != nil && runtime.LeaseExpiresAt.After(leaseCheckedAt) {
|
||||
return pagesOrphanCleanupLeaseBusy, false, nil
|
||||
}
|
||||
return pagesOrphanCleanupSkipped, true, nil
|
||||
}
|
||||
|
||||
func reconcileLockedPagesOrphanUpload(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
uploadID uint64,
|
||||
marker pagesOrphanMarker,
|
||||
systemUserID uint64,
|
||||
cutoff time.Time,
|
||||
) (pagesOrphanCleanupOutcome, bool, error) {
|
||||
var lockedUpload model.Upload
|
||||
found, err := lockOptionalPagesCleanupRecord(tx, &lockedUpload, "id = ?", uploadID)
|
||||
if err != nil || !found {
|
||||
return pagesOrphanCleanupSkipped, false, err
|
||||
}
|
||||
|
||||
lockedMarker, err := parsePagesOrphanMarker(lockedUpload.Metadata)
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] orphan upload marker changed or invalid: upload_id=%d error=%v", uploadID, err)
|
||||
return pagesOrphanCleanupInvalidMarker, true, nil
|
||||
}
|
||||
if lockedUpload.Status != model.UploadStatusUsed ||
|
||||
lockedUpload.UserID != systemUserID ||
|
||||
lockedUpload.Type != ofupload.ReservedPagesDeploymentType ||
|
||||
!lockedUpload.CreatedAt.Before(cutoff) {
|
||||
return pagesOrphanCleanupSkipped, true, nil
|
||||
}
|
||||
if lockedMarker.ProjectID != marker.ProjectID || !sameOptionalPagesSourceID(lockedMarker.SourceID, marker.SourceID) {
|
||||
logger.WarnF(ctx, "[PagesSource] orphan upload marker changed during reconciliation: upload_id=%d", uploadID)
|
||||
return pagesOrphanCleanupInvalidMarker, true, nil
|
||||
}
|
||||
|
||||
var references int64
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("upload_id = ?", lockedUpload.ID).
|
||||
Count(&references).Error; err != nil {
|
||||
return pagesOrphanCleanupSkipped, true, err
|
||||
}
|
||||
if references > 0 {
|
||||
return pagesOrphanCleanupReferenced, true, nil
|
||||
}
|
||||
|
||||
transitioned, err := ofupload.RemoveLockedTx(tx, &lockedUpload)
|
||||
if err != nil {
|
||||
return pagesOrphanCleanupSkipped, true, err
|
||||
}
|
||||
if transitioned {
|
||||
return pagesOrphanCleanupReconciled, true, nil
|
||||
}
|
||||
return pagesOrphanCleanupSkipped, true, nil
|
||||
}
|
||||
|
||||
func lockOptionalPagesCleanupRecord(
|
||||
tx *gorm.DB,
|
||||
value any,
|
||||
query string,
|
||||
args ...any,
|
||||
) (bool, error) {
|
||||
err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where(query, args...).
|
||||
First(value).Error
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func parsePagesOrphanMarker(metadata model.UploadMetadata) (pagesOrphanMarker, error) {
|
||||
if metadata.Extra == nil {
|
||||
return pagesOrphanMarker{}, errors.New("pages marker metadata missing")
|
||||
}
|
||||
marker, ok := metadata.Extra[pagesIngestMarkerKey].(string)
|
||||
if !ok || marker != pagesIngestMarkerV2 {
|
||||
return pagesOrphanMarker{}, errors.New("pages marker version invalid")
|
||||
}
|
||||
projectID, err := parsePagesOrphanMetadataID(metadata.Extra, pagesProjectIDMetadataKey)
|
||||
if err != nil {
|
||||
return pagesOrphanMarker{}, err
|
||||
}
|
||||
result := pagesOrphanMarker{ProjectID: projectID}
|
||||
if _, exists := metadata.Extra[pagesSourceIDMetadataKey]; exists {
|
||||
sourceID, err := parsePagesOrphanMetadataID(metadata.Extra, pagesSourceIDMetadataKey)
|
||||
if err != nil {
|
||||
return pagesOrphanMarker{}, err
|
||||
}
|
||||
result.SourceID = &sourceID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parsePagesOrphanMetadataID(extra map[string]any, key string) (uint, error) {
|
||||
raw, exists := extra[key]
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("pages marker %s missing", key)
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok || value == "" {
|
||||
return 0, fmt.Errorf("pages marker %s must be a decimal string", key)
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
maxModelID := uint64(^uint(0) >> 1)
|
||||
if err != nil || parsed == 0 || parsed > maxModelID || strconv.FormatUint(parsed, 10) != value {
|
||||
return 0, fmt.Errorf("pages marker %s is not a canonical non-zero decimal ID", key)
|
||||
}
|
||||
return uint(parsed), nil
|
||||
}
|
||||
|
||||
func sameOptionalPagesSourceID(left, right *uint) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func TestParsePagesOrphanMarker(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
wantProject uint
|
||||
wantSource uint
|
||||
wantSourceOK bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "manual source marker",
|
||||
extra: map[string]any{
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: "12",
|
||||
},
|
||||
wantProject: 12,
|
||||
},
|
||||
{
|
||||
name: "persistent source marker",
|
||||
extra: map[string]any{
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: "12",
|
||||
pagesSourceIDMetadataKey: "34",
|
||||
},
|
||||
wantProject: 12,
|
||||
wantSource: 34,
|
||||
wantSourceOK: true,
|
||||
},
|
||||
{
|
||||
name: "project ID must be canonical decimal",
|
||||
extra: map[string]any{
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: "012",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "source ID must be a string",
|
||||
extra: map[string]any{
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: "12",
|
||||
pagesSourceIDMetadataKey: float64(34),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero ID rejected",
|
||||
extra: map[string]any{
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: "0",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong marker version rejected",
|
||||
extra: map[string]any{
|
||||
pagesIngestMarkerKey: "pages_deployment_v1",
|
||||
pagesProjectIDMetadataKey: "12",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := parsePagesOrphanMarker(model.UploadMetadata{Extra: test.extra})
|
||||
if gotErr := err != nil; gotErr != test.wantErr {
|
||||
t.Fatalf("parsePagesOrphanMarker(%v) error = %v, want error presence = %t", test.extra, err, test.wantErr)
|
||||
}
|
||||
if test.wantErr {
|
||||
return
|
||||
}
|
||||
if got.ProjectID != test.wantProject {
|
||||
t.Errorf("parsePagesOrphanMarker(%v).ProjectID = %d, want %d", test.extra, got.ProjectID, test.wantProject)
|
||||
}
|
||||
if gotSourceOK := got.SourceID != nil; gotSourceOK != test.wantSourceOK {
|
||||
t.Fatalf("parsePagesOrphanMarker(%v).SourceID presence = %t, want %t", test.extra, gotSourceOK, test.wantSourceOK)
|
||||
}
|
||||
if got.SourceID != nil && *got.SourceID != test.wantSource {
|
||||
t.Errorf("parsePagesOrphanMarker(%v).SourceID = %d, want %d", test.extra, *got.SourceID, test.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePagesOrphanUploadsDeletesEligibleUploadOnce(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
project := createPagesOrphanProject(t, ctx, "eligible-orphan")
|
||||
candidate := createPagesOrphanUpload(t, ctx, now.Add(-3*time.Hour), project.ID, nil)
|
||||
if err := ofupload.RebuildUploadStats(ctx); err != nil {
|
||||
t.Fatalf("RebuildUploadStats() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
summary, err := ReconcilePagesOrphanUploads(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcilePagesOrphanUploads() error = %v, want nil", err)
|
||||
}
|
||||
if summary.Candidates != 1 || summary.Reconciled != 1 || cleanupOutcomeTotal(summary) != 1 {
|
||||
t.Errorf("ReconcilePagesOrphanUploads() summary = %+v, want one reconciled candidate", summary)
|
||||
}
|
||||
assertPagesCleanupUploadStatus(t, ctx, candidate.ID, model.UploadStatusDeleted)
|
||||
assertPagesCleanupTotalStat(t, ctx, 0)
|
||||
|
||||
second, err := ReconcilePagesOrphanUploads(ctx, now.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("second ReconcilePagesOrphanUploads() error = %v, want nil", err)
|
||||
}
|
||||
if second.Candidates != 0 || cleanupOutcomeTotal(second) != 0 {
|
||||
t.Errorf("second ReconcilePagesOrphanUploads() summary = %+v, want empty", second)
|
||||
}
|
||||
assertPagesCleanupTotalStat(t, ctx, 0)
|
||||
}
|
||||
|
||||
func TestReconcilePagesOrphanUploadsAllowsDeletedProjectAndSource(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
missingSourceID := uint(9876)
|
||||
candidate := createPagesOrphanUpload(t, ctx, now.Add(-3*time.Hour), 8765, &missingSourceID)
|
||||
|
||||
summary, err := ReconcilePagesOrphanUploads(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcilePagesOrphanUploads() error = %v, want nil", err)
|
||||
}
|
||||
if summary.Candidates != 1 || summary.Reconciled != 1 || cleanupOutcomeTotal(summary) != 1 {
|
||||
t.Errorf("ReconcilePagesOrphanUploads() summary = %+v, want deleted project/source treated as one orphan", summary)
|
||||
}
|
||||
assertPagesCleanupUploadStatus(t, ctx, candidate.ID, model.UploadStatusDeleted)
|
||||
}
|
||||
|
||||
func TestReconcilePagesOrphanUploadsSkipsBusyLeaseAndSourceMismatch(t *testing.T) {
|
||||
t.Run("unexpired source lease", func(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
realNow := time.Now().UTC()
|
||||
// A deliberately future scanner snapshot proves lease freshness uses the
|
||||
// real clock after the runtime lock, not this isolation-cutoff input.
|
||||
scannerNow := realNow.Add(24 * time.Hour)
|
||||
project := createPagesOrphanProject(t, ctx, "busy-orphan")
|
||||
source := createPagesOrphanSource(t, ctx, project.ID)
|
||||
future := realNow.Add(time.Hour)
|
||||
if err := db.DB(ctx).Create(&model.PagesProjectSourceRuntime{
|
||||
SourceID: source.ID,
|
||||
LeaseToken: "busy-worker",
|
||||
LeaseExpiresAt: &future,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create busy source runtime error = %v, want nil", err)
|
||||
}
|
||||
candidate := createPagesOrphanUpload(t, ctx, realNow.Add(-3*time.Hour), project.ID, &source.ID)
|
||||
|
||||
summary, err := ReconcilePagesOrphanUploads(ctx, scannerNow)
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcilePagesOrphanUploads() error = %v, want nil", err)
|
||||
}
|
||||
if summary.LeaseBusy != 1 || cleanupOutcomeTotal(summary) != 1 {
|
||||
t.Errorf("ReconcilePagesOrphanUploads() summary = %+v, want one lease-busy candidate", summary)
|
||||
}
|
||||
assertPagesCleanupUploadStatus(t, ctx, candidate.ID, model.UploadStatusUsed)
|
||||
})
|
||||
|
||||
t.Run("source belongs to another project", func(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
markerProject := createPagesOrphanProject(t, ctx, "marker-project")
|
||||
actualProject := createPagesOrphanProject(t, ctx, "actual-project")
|
||||
source := createPagesOrphanSource(t, ctx, actualProject.ID)
|
||||
candidate := createPagesOrphanUpload(t, ctx, now.Add(-3*time.Hour), markerProject.ID, &source.ID)
|
||||
|
||||
summary, err := ReconcilePagesOrphanUploads(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcilePagesOrphanUploads() error = %v, want nil", err)
|
||||
}
|
||||
if summary.InvalidMarker != 1 || cleanupOutcomeTotal(summary) != 1 {
|
||||
t.Errorf("ReconcilePagesOrphanUploads() summary = %+v, want one ownership mismatch", summary)
|
||||
}
|
||||
assertPagesCleanupUploadStatus(t, ctx, candidate.ID, model.UploadStatusUsed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReconcilePagesOrphanUploadsRejectsMalformedMarker(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
candidate := createPagesOrphanUpload(t, ctx, now.Add(-3*time.Hour), 1, nil)
|
||||
metadata := candidate.Metadata
|
||||
metadata.Extra[pagesProjectIDMetadataKey] = "01"
|
||||
candidate.Metadata = metadata
|
||||
if err := db.DB(ctx).Save(candidate).Error; err != nil {
|
||||
t.Fatalf("seed malformed candidate marker error = %v, want nil", err)
|
||||
}
|
||||
|
||||
summary, err := ReconcilePagesOrphanUploads(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcilePagesOrphanUploads() error = %v, want nil", err)
|
||||
}
|
||||
if summary.InvalidMarker != 1 || cleanupOutcomeTotal(summary) != 1 {
|
||||
t.Errorf("ReconcilePagesOrphanUploads() summary = %+v, want one invalid marker", summary)
|
||||
}
|
||||
assertPagesCleanupUploadStatus(t, ctx, candidate.ID, model.UploadStatusUsed)
|
||||
}
|
||||
|
||||
func TestPagesOrphanCleanupAndDeploymentCommitInterleavings(t *testing.T) {
|
||||
t.Run("deployment reference commits first", func(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
project := createPagesOrphanProject(t, ctx, "deployment-first")
|
||||
candidate := createPagesOrphanUpload(t, ctx, now.Add(-3*time.Hour), project.ID, nil)
|
||||
marker, err := parsePagesOrphanMarker(candidate.Metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePagesOrphanMarker() error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
DeploymentNumber: 1,
|
||||
Checksum: "deployment-first",
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
UploadID: candidate.ID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create deployment reference error = %v, want nil", err)
|
||||
}
|
||||
|
||||
outcome, err := reconcilePagesOrphanUploadCandidate(ctx, candidate, marker, 999, now.Add(-2*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("reconcilePagesOrphanUploadCandidate() error = %v, want nil", err)
|
||||
}
|
||||
if outcome != pagesOrphanCleanupReferenced {
|
||||
t.Errorf("reconcilePagesOrphanUploadCandidate() outcome = %d, want %d", outcome, pagesOrphanCleanupReferenced)
|
||||
}
|
||||
assertPagesCleanupUploadStatus(t, ctx, candidate.ID, model.UploadStatusUsed)
|
||||
})
|
||||
|
||||
t.Run("cleanup commits first", func(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
project := createPagesOrphanProject(t, ctx, "cleanup-first")
|
||||
candidate := createPagesOrphanUpload(t, ctx, now.Add(-3*time.Hour), project.ID, nil)
|
||||
|
||||
summary, err := ReconcilePagesOrphanUploads(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcilePagesOrphanUploads() error = %v, want nil", err)
|
||||
}
|
||||
if summary.Reconciled != 1 {
|
||||
t.Fatalf("ReconcilePagesOrphanUploads() summary = %+v, want one reconciled candidate", summary)
|
||||
}
|
||||
|
||||
target := &model.PagesDeployment{ProjectID: project.ID, UploadID: candidate.ID}
|
||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var lockedProject model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&lockedProject, project.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return lockSourceDeploymentUploadsTx(tx, target, ofupload.IngestResult{}, false)
|
||||
})
|
||||
if !errors.Is(err, errSourceFinalFence) {
|
||||
t.Errorf("final deployment upload lock after cleanup error = %v, want %v", err, errSourceFinalFence)
|
||||
}
|
||||
var references int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("upload_id = ?", candidate.ID).Count(&references).Error; err != nil {
|
||||
t.Fatalf("count deployment references error = %v, want nil", err)
|
||||
}
|
||||
if references != 0 {
|
||||
t.Errorf("deployment references after cleanup-first interleaving = %d, want 0", references)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func cleanupOutcomeTotal(summary PagesOrphanCleanupSummary) int {
|
||||
return summary.Reconciled + summary.Referenced + summary.LeaseBusy + summary.InvalidMarker + summary.Skipped + summary.Failed
|
||||
}
|
||||
|
||||
func createPagesOrphanProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
|
||||
t.Helper()
|
||||
project := &model.PagesProject{Name: slug, Slug: slug, Enabled: true}
|
||||
if err := db.DB(ctx).Create(project).Error; err != nil {
|
||||
t.Fatalf("create Pages orphan project %q error = %v, want nil", slug, err)
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
func createPagesOrphanSource(t *testing.T, ctx context.Context, projectID uint) *model.PagesProjectSource {
|
||||
t.Helper()
|
||||
source := &model.PagesProjectSource{
|
||||
ProjectID: projectID,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
ConfigVersion: 1,
|
||||
SourceIdentity: "orphan-source-identity",
|
||||
}
|
||||
if err := db.DB(ctx).Create(source).Error; err != nil {
|
||||
t.Fatalf("create Pages orphan source for project %d error = %v, want nil", projectID, err)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func createPagesOrphanUpload(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
createdAt time.Time,
|
||||
projectID uint,
|
||||
sourceID *uint,
|
||||
) *model.Upload {
|
||||
t.Helper()
|
||||
extra := map[string]any{
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: strconv.FormatUint(uint64(projectID), 10),
|
||||
}
|
||||
if sourceID != nil {
|
||||
extra[pagesSourceIDMetadataKey] = strconv.FormatUint(uint64(*sourceID), 10)
|
||||
}
|
||||
candidate := &model.Upload{
|
||||
UserID: 999,
|
||||
FileName: "site.zip",
|
||||
FilePath: "pages/orphan-site.zip",
|
||||
FileSize: 64,
|
||||
MimeType: "application/zip",
|
||||
Extension: "zip",
|
||||
Hash: "orphan-checksum",
|
||||
Type: ofupload.ReservedPagesDeploymentType,
|
||||
Status: model.UploadStatusUsed,
|
||||
AccessMode: 0,
|
||||
Metadata: model.UploadMetadata{Extra: extra},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
if err := db.DB(ctx).Create(candidate).Error; err != nil {
|
||||
t.Fatalf("create Pages orphan upload error = %v, want nil", err)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func assertPagesCleanupUploadStatus(t *testing.T, ctx context.Context, uploadID uint64, want model.UploadStatus) {
|
||||
t.Helper()
|
||||
var got model.Upload
|
||||
if err := db.DB(ctx).First(&got, uploadID).Error; err != nil {
|
||||
t.Fatalf("load upload %d error = %v, want nil", uploadID, err)
|
||||
}
|
||||
if got.Status != want {
|
||||
t.Errorf("upload %d status = %q, want %q", uploadID, got.Status, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPagesCleanupTotalStat(t *testing.T, ctx context.Context, want int64) {
|
||||
t.Helper()
|
||||
var stat model.UploadStat
|
||||
if err := db.DB(ctx).Where("dimension = ? AND stat_key = ?", model.UploadStatDimensionTotal, "").First(&stat).Error; err != nil {
|
||||
t.Fatalf("load total upload stat error = %v, want nil", err)
|
||||
}
|
||||
if stat.FileCount != want {
|
||||
t.Errorf("total upload stat FileCount = %d, want %d", stat.FileCount, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/share/pagesarchive"
|
||||
"Wavelet/pkg/httppool"
|
||||
)
|
||||
|
||||
const (
|
||||
remoteSourceDownloadTimeout = 10 * time.Minute
|
||||
remoteSourceResponseHeaderTimeout = 30 * time.Second
|
||||
remoteSourceDialTimeout = 30 * time.Second
|
||||
remoteSourceDialKeepAlive = 30 * time.Second
|
||||
remoteSourceMaxRedirects = 5
|
||||
remoteSourceMagicSniffBytes = 512
|
||||
remoteSourceMaxSafeLabelBytes = 255
|
||||
remoteSourceFallbackLabel = "package"
|
||||
remoteSourceUserAgent = "OpenFlare Pages Source/2"
|
||||
remoteSourceSchemeHTTP = "http"
|
||||
remoteSourceSchemeHTTPS = "https"
|
||||
)
|
||||
|
||||
type remoteProviderError string
|
||||
|
||||
func (providerError remoteProviderError) Error() string {
|
||||
return string(providerError)
|
||||
}
|
||||
|
||||
const (
|
||||
errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效"
|
||||
errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制"
|
||||
errRemoteProviderDownloadFailed remoteProviderError = errPagesPackageURLDownloadFailed
|
||||
errRemoteProviderTooLarge remoteProviderError = errPagesPackageURLTooLarge
|
||||
errRemoteProviderEmpty remoteProviderError = errPagesPackageEmpty
|
||||
errRemoteProviderUnsupported remoteProviderError = errPagesPackageUnsupported
|
||||
errRemoteProviderCleanupFailed remoteProviderError = "清理远程来源临时文件失败"
|
||||
)
|
||||
|
||||
// RemoteSourceRequest describes one immutable Remote URL package fetch.
|
||||
type RemoteSourceRequest struct {
|
||||
URL string
|
||||
AllowInsecure bool
|
||||
MaxPackageBytes int64
|
||||
}
|
||||
|
||||
// SourceCandidate is a constrained, immutable archive downloaded to a
|
||||
// provider-owned temporary file. The caller owns the file after a successful
|
||||
// fetch and must call Cleanup when processing finishes.
|
||||
type SourceCandidate struct {
|
||||
TempPath string
|
||||
Checksum string
|
||||
PackageSize int64
|
||||
Format pagesarchive.Format
|
||||
SafeLabel string
|
||||
}
|
||||
|
||||
// Cleanup removes the candidate temporary file. It is safe to call repeatedly.
|
||||
func (candidate *SourceCandidate) Cleanup() error {
|
||||
if candidate == nil || candidate.TempPath == "" {
|
||||
return nil
|
||||
}
|
||||
tempPath := candidate.TempPath
|
||||
err := os.Remove(tempPath)
|
||||
if err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
candidate.TempPath = ""
|
||||
return nil
|
||||
}
|
||||
return errRemoteProviderCleanupFailed
|
||||
}
|
||||
|
||||
type remoteSourceResolver interface {
|
||||
LookupNetIP(context.Context, string, string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
type remoteSourceDependencies struct {
|
||||
resolver remoteSourceResolver
|
||||
dialContext func(context.Context, string, string) (net.Conn, error)
|
||||
createTemp func(string, string) (*os.File, error)
|
||||
}
|
||||
|
||||
// FetchRemoteSource downloads a Remote URL package without writing deployment
|
||||
// state. Errors are reduced to safe domain messages and never contain the raw
|
||||
// URL, query, response headers or response body.
|
||||
func FetchRemoteSource(ctx context.Context, request RemoteSourceRequest) (*SourceCandidate, error) {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: remoteSourceDialTimeout,
|
||||
KeepAlive: remoteSourceDialKeepAlive,
|
||||
}
|
||||
dependencies := remoteSourceDependencies{
|
||||
resolver: net.DefaultResolver,
|
||||
dialContext: dialer.DialContext,
|
||||
createTemp: os.CreateTemp,
|
||||
}
|
||||
return fetchRemoteSource(ctx, request, dependencies)
|
||||
}
|
||||
|
||||
func fetchRemoteSource(ctx context.Context, request RemoteSourceRequest, dependencies remoteSourceDependencies) (*SourceCandidate, error) {
|
||||
if request.MaxPackageBytes <= 0 {
|
||||
return nil, errRemoteProviderInvalidLimit
|
||||
}
|
||||
if dependencies.dialContext == nil || dependencies.createTemp == nil {
|
||||
return nil, errRemoteProviderDownloadFailed
|
||||
}
|
||||
parsed, err := parseRemoteSourceURL(request.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRemoteSourceTarget(ctx, parsed); err != nil {
|
||||
return nil, sanitizeRemoteProviderError(ctx, err)
|
||||
}
|
||||
|
||||
safeLabel, namedFormat := remoteSourceLabel(parsed)
|
||||
client := newRemoteSourceClient(request.AllowInsecure, dependencies)
|
||||
defer client.CloseIdleConnections()
|
||||
response, err := requestRemoteSource(ctx, client, parsed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("%w: HTTP %d", errRemoteProviderDownloadFailed, response.StatusCode)
|
||||
}
|
||||
if response.ContentLength > request.MaxPackageBytes {
|
||||
return nil, errRemoteProviderTooLarge
|
||||
}
|
||||
|
||||
tempPath, checksum, packageSize, err := streamRemoteSourcePackage(
|
||||
response.Body,
|
||||
request.MaxPackageBytes,
|
||||
dependencies.createTemp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, sanitizeRemoteProviderError(ctx, err)
|
||||
}
|
||||
format, safeLabel, err := detectRemoteSourceFormat(tempPath, safeLabel, namedFormat)
|
||||
if err != nil {
|
||||
if removeErr := os.Remove(tempPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||
return nil, errRemoteProviderCleanupFailed
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SourceCandidate{
|
||||
TempPath: tempPath,
|
||||
Checksum: checksum,
|
||||
PackageSize: packageSize,
|
||||
Format: format,
|
||||
SafeLabel: safeLabel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newRemoteSourceClient(allowInsecure bool, dependencies remoteSourceDependencies) *http.Client {
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if allowInsecure {
|
||||
// Explicit administrator choice for self-signed or private CA endpoints.
|
||||
tlsConfig.InsecureSkipVerify = true //nolint:gosec // required allow_insecure semantics
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: remoteSourceDownloadTimeout,
|
||||
Transport: httppool.NewTransport(httppool.TransportOptions{
|
||||
Proxy: nil,
|
||||
DialContext: dependencies.dialContext,
|
||||
TLSClientConfig: tlsConfig,
|
||||
ResponseHeaderTimeout: remoteSourceResponseHeaderTimeout,
|
||||
TraceFilter: remoteSourceTraceFilter,
|
||||
}),
|
||||
}
|
||||
client.CheckRedirect = func(next *http.Request, previous []*http.Request) error {
|
||||
if len(previous) > remoteSourceMaxRedirects {
|
||||
return errRemoteProviderRedirectLimit
|
||||
}
|
||||
stripRemoteSourceRedirectHeaders(next)
|
||||
if err := validateRemoteSourceTarget(next.Context(), next.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
applyRemoteSourceHeaders(next)
|
||||
return nil
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func requestRemoteSource(ctx context.Context, client *http.Client, parsed *url.URL) (*http.Response, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
applyRemoteSourceHeaders(request)
|
||||
response, err := client.Do(request) //nolint:gosec // scheme and every dial target are validated above
|
||||
if err == nil {
|
||||
return response, nil
|
||||
}
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
return nil, sanitizeRemoteProviderError(ctx, err)
|
||||
}
|
||||
|
||||
func applyRemoteSourceHeaders(request *http.Request) {
|
||||
request.Header.Set("User-Agent", remoteSourceUserAgent)
|
||||
request.Header.Set("Accept", "application/octet-stream,application/zip,application/x-tar,application/gzip,*/*;q=0.1")
|
||||
// Preserve the artifact bytes exactly as stored. Automatic HTTP gzip
|
||||
// decompression would change the checksum, size and archive format.
|
||||
request.Header.Set("Accept-Encoding", "identity")
|
||||
}
|
||||
|
||||
func stripRemoteSourceRedirectHeaders(request *http.Request) {
|
||||
request.Header.Del("Authorization")
|
||||
request.Header.Del("Cookie")
|
||||
request.Header.Del("Proxy-Authorization")
|
||||
request.Header.Del("Referer")
|
||||
}
|
||||
|
||||
func remoteSourceTraceFilter(request *http.Request) bool {
|
||||
// otelhttp records url.full. Signed query strings must never enter traces.
|
||||
return request.URL == nil || request.URL.RawQuery == ""
|
||||
}
|
||||
|
||||
func validateRemoteSourceTarget(_ context.Context, target *url.URL) error {
|
||||
if target == nil || target.User != nil || target.Fragment != "" || target.Opaque != "" {
|
||||
return errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(target.Scheme))
|
||||
if (scheme != remoteSourceSchemeHTTP && scheme != remoteSourceSchemeHTTPS) || strings.TrimSpace(target.Hostname()) == "" {
|
||||
return errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamRemoteSourcePackage(
|
||||
body io.Reader,
|
||||
maxPackageBytes int64,
|
||||
createTemp func(string, string) (*os.File, error),
|
||||
) (tempPath string, checksum string, packageSize int64, err error) {
|
||||
if createTemp == nil {
|
||||
return "", "", 0, errRemoteProviderDownloadFailed
|
||||
}
|
||||
tempFile, err := createTemp("", "openflare-pages-source-*")
|
||||
if err != nil {
|
||||
return "", "", 0, errRemoteProviderDownloadFailed
|
||||
}
|
||||
createdTempPath := tempFile.Name()
|
||||
tempPath = createdTempPath
|
||||
defer func() {
|
||||
closeErr := tempFile.Close()
|
||||
if err == nil && closeErr != nil {
|
||||
err = errRemoteProviderDownloadFailed
|
||||
}
|
||||
if err != nil {
|
||||
if removeErr := os.Remove(createdTempPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||
err = errRemoteProviderCleanupFailed
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
readLimit := maxPackageBytes
|
||||
if readLimit < math.MaxInt64 {
|
||||
readLimit++
|
||||
}
|
||||
packageSize, err = io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(body, readLimit))
|
||||
if err != nil {
|
||||
return "", "", 0, errRemoteProviderDownloadFailed
|
||||
}
|
||||
if packageSize > maxPackageBytes {
|
||||
return "", "", 0, errRemoteProviderTooLarge
|
||||
}
|
||||
if packageSize == 0 {
|
||||
return "", "", 0, errRemoteProviderEmpty
|
||||
}
|
||||
checksum = hex.EncodeToString(hasher.Sum(nil))
|
||||
return tempPath, checksum, packageSize, nil
|
||||
}
|
||||
|
||||
func detectRemoteSourceFormat(
|
||||
tempPath string,
|
||||
safeLabel string,
|
||||
namedFormat pagesarchive.Format,
|
||||
) (pagesarchive.Format, string, error) {
|
||||
if namedFormat != "" {
|
||||
return namedFormat, safeLabel, nil
|
||||
}
|
||||
tempFile, err := os.Open(tempPath) //nolint:gosec // path is a provider-created temporary file
|
||||
if err != nil {
|
||||
return "", safeLabel, errRemoteProviderDownloadFailed
|
||||
}
|
||||
defer func() { _ = tempFile.Close() }()
|
||||
|
||||
head := make([]byte, remoteSourceMagicSniffBytes)
|
||||
readBytes, readErr := io.ReadFull(tempFile, head)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
||||
return "", safeLabel, errRemoteProviderDownloadFailed
|
||||
}
|
||||
format, ok := pagesarchive.DetectFormatFromBytes(head[:readBytes])
|
||||
if !ok {
|
||||
return "", safeLabel, errRemoteProviderUnsupported
|
||||
}
|
||||
return format, appendRemoteSourceLabelExtension(safeLabel, format), nil
|
||||
}
|
||||
|
||||
func remoteSourceLabel(parsed *url.URL) (string, pagesarchive.Format) {
|
||||
baseName := path.Base(parsed.Path)
|
||||
if baseName == "" || baseName == "." || baseName == "/" {
|
||||
baseName = remoteSourceFallbackLabel
|
||||
}
|
||||
safeLabel := sanitizeRemoteSourceLabel(baseName)
|
||||
format, _ := pagesarchive.DetectFormatFromName(safeLabel)
|
||||
return limitRemoteSourceLabel(safeLabel, format), format
|
||||
}
|
||||
|
||||
func sanitizeRemoteSourceLabel(label string) string {
|
||||
var builder strings.Builder
|
||||
lastReplacement := false
|
||||
for _, character := range label {
|
||||
if isRemoteSourceLabelCharacter(character) {
|
||||
builder.WriteRune(character)
|
||||
lastReplacement = false
|
||||
continue
|
||||
}
|
||||
if !lastReplacement {
|
||||
builder.WriteByte('-')
|
||||
lastReplacement = true
|
||||
}
|
||||
}
|
||||
safeLabel := strings.TrimSpace(builder.String())
|
||||
if safeLabel == "" || strings.Trim(safeLabel, "._-") == "" {
|
||||
return remoteSourceFallbackLabel
|
||||
}
|
||||
return safeLabel
|
||||
}
|
||||
|
||||
func isRemoteSourceLabelCharacter(character rune) bool {
|
||||
return character >= 'a' && character <= 'z' ||
|
||||
character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' ||
|
||||
character == '.' || character == '-' || character == '_'
|
||||
}
|
||||
|
||||
func limitRemoteSourceLabel(label string, format pagesarchive.Format) string {
|
||||
if len(label) <= remoteSourceMaxSafeLabelBytes {
|
||||
return label
|
||||
}
|
||||
if format == "" {
|
||||
return strings.TrimRight(label[:remoteSourceMaxSafeLabelBytes], ".-_")
|
||||
}
|
||||
extension := "." + pagesarchive.Extension(format)
|
||||
prefixLength := remoteSourceMaxSafeLabelBytes - len(extension)
|
||||
prefix := strings.TrimRight(label[:prefixLength], ".-_")
|
||||
if prefix == "" {
|
||||
prefix = remoteSourceFallbackLabel
|
||||
}
|
||||
return prefix + extension
|
||||
}
|
||||
|
||||
func appendRemoteSourceLabelExtension(label string, format pagesarchive.Format) string {
|
||||
extension := "." + pagesarchive.Extension(format)
|
||||
maxPrefixLength := remoteSourceMaxSafeLabelBytes - len(extension)
|
||||
if len(label) > maxPrefixLength {
|
||||
label = strings.TrimRight(label[:maxPrefixLength], ".-_")
|
||||
}
|
||||
if label == "" {
|
||||
label = remoteSourceFallbackLabel
|
||||
}
|
||||
return label + extension
|
||||
}
|
||||
|
||||
func sanitizeRemoteProviderError(ctx context.Context, err error) error {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return fmt.Errorf("%w: %w", errRemoteProviderDownloadFailed, ctxErr)
|
||||
}
|
||||
for _, safeError := range []error{
|
||||
errRemoteProviderInvalidLimit,
|
||||
errRemoteProviderRedirectLimit,
|
||||
errRemoteProviderTooLarge,
|
||||
errRemoteProviderEmpty,
|
||||
errRemoteProviderUnsupported,
|
||||
errRemoteProviderCleanupFailed,
|
||||
} {
|
||||
if errors.Is(err, safeError) {
|
||||
return safeError
|
||||
}
|
||||
}
|
||||
return errRemoteProviderDownloadFailed
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type remoteSourceResolverFunc func(context.Context, string, string) ([]netip.Addr, error)
|
||||
|
||||
func (function remoteSourceResolverFunc) LookupNetIP(
|
||||
ctx context.Context,
|
||||
network string,
|
||||
host string,
|
||||
) ([]netip.Addr, error) {
|
||||
return function(ctx, network, host)
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourceTrustedInternalSelfSignedAndSafeLabel(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Query().Get("token") != "source-secret" {
|
||||
t.Error("signed query did not reach the artifact server")
|
||||
}
|
||||
if request.Header.Get("Accept-Encoding") != "identity" {
|
||||
t.Error("artifact request must disable automatic HTTP decompression")
|
||||
}
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="redirected.tar.gz"`)
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/original/site.zip?token=source-secret",
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
if candidate.Format != "zip" {
|
||||
t.Fatalf("Format = %q, want zip", candidate.Format)
|
||||
}
|
||||
if candidate.SafeLabel != "site.zip" {
|
||||
t.Fatalf("SafeLabel = %q, want original path basename", candidate.SafeLabel)
|
||||
}
|
||||
if candidate.PackageSize != int64(len(packageBytes)) {
|
||||
t.Fatalf("PackageSize = %d, want %d", candidate.PackageSize, len(packageBytes))
|
||||
}
|
||||
wantChecksum := sha256.Sum256(packageBytes)
|
||||
if candidate.Checksum != hex.EncodeToString(wantChecksum[:]) {
|
||||
t.Fatalf("Checksum = %q, want SHA-256", candidate.Checksum)
|
||||
}
|
||||
downloaded, err := os.ReadFile(candidate.TempPath) //nolint:gosec // provider-owned test temp file
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(downloaded, packageBytes) {
|
||||
t.Fatal("downloaded package differs from response body")
|
||||
}
|
||||
tempPath := candidate.TempPath
|
||||
if err := candidate.Cleanup(); err != nil {
|
||||
t.Fatalf("Cleanup() error = %v", err)
|
||||
}
|
||||
if err := candidate.Cleanup(); err != nil {
|
||||
t.Fatalf("second Cleanup() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("temporary file still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourceKeepsOriginalLabelAcrossRedirect(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/original/site.zip" {
|
||||
writer.Header().Set("Location", "/delivery/final.tar.gz?token=redirect-secret")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
return
|
||||
}
|
||||
if request.Header.Get("Referer") != "" {
|
||||
t.Error("redirect must not forward a signed source URL as Referer")
|
||||
}
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="response.7z"`)
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/original/site.zip?token=initial-secret",
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.SafeLabel != "site.zip" || candidate.Format != "zip" {
|
||||
t.Fatalf("candidate = label %q format %q, want original site.zip", candidate.SafeLabel, candidate.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicAllowsPrivateAddresses(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/site.zip?token=private-secret",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.Format != "zip" {
|
||||
t.Fatalf("candidate format = %q, want zip", candidate.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicUsesDirectDialer(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
var dialedAddress string
|
||||
dependencies := mappedRemoteSourceDependencies(server.Listener.Addr().String(), staticPublicRemoteSourceResolver())
|
||||
dependencies.dialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
dialedAddress = address
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, network, server.Listener.Addr().String())
|
||||
}
|
||||
candidate, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: "http://artifact.example/site.zip",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRemoteSource() error = %v", err)
|
||||
}
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if dialedAddress != "artifact.example:80" {
|
||||
t.Fatalf("direct dial address = %q, want hostname dial", dialedAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicAllowsPrivateRedirect(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
var privateServer *httptest.Server
|
||||
privateServer = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(privateServer.Close)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Location", privateServer.URL+"/private.zip?token=redirect-secret")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/start.zip?token=initial-secret",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.Format != "zip" {
|
||||
t.Fatalf("candidate format = %q, want zip", candidate.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsSelfSignedTLS(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
dependencies := mappedRemoteSourceDependencies(server.Listener.Addr().String(), staticPublicRemoteSourceResolver())
|
||||
rawURL := "https://artifact.example/site.zip?signature=tls-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderDownloadFailed) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want strict TLS failure", err)
|
||||
}
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "tls-secret", "signature=")
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourceRejectsChunkedBodyOverLimitAndCleansTemp(t *testing.T) {
|
||||
const maxPackageBytes = int64(64)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, int(maxPackageBytes)))
|
||||
if flusher, ok := writer.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = writer.Write([]byte("overflow"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
dependencies := defaultRemoteSourceDependenciesForTest()
|
||||
dependencies.createTemp = func(_ string, pattern string) (*os.File, error) {
|
||||
return os.CreateTemp(tempDir, pattern)
|
||||
}
|
||||
rawURL := server.URL + "/site.zip?token=chunk-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: maxPackageBytes,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderTooLarge) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want actual stream limit", err)
|
||||
}
|
||||
assertRemoteSourceTempDirEmpty(t, tempDir)
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "chunk-secret", "token=")
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourceRejectsContentLengthBeforeCreatingTemp(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Length", "4096")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
var createCount atomic.Int32
|
||||
dependencies := defaultRemoteSourceDependenciesForTest()
|
||||
dependencies.createTemp = func(directory string, pattern string) (*os.File, error) {
|
||||
createCount.Add(1)
|
||||
return os.CreateTemp(directory, pattern)
|
||||
}
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/site.zip",
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderTooLarge) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want Content-Length rejection", err)
|
||||
}
|
||||
if createCount.Load() != 0 {
|
||||
t.Fatalf("CreateTemp called %d times before Content-Length rejection", createCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourceSniffsAtLeast512BytesForTar(t *testing.T) {
|
||||
packageBytes := make([]byte, remoteSourceMagicSniffBytes)
|
||||
copy(packageBytes[257:], []byte("ustar"))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/download",
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.Format != "tar" {
|
||||
t.Fatalf("Format = %q, want tar detected at byte 257", candidate.Format)
|
||||
}
|
||||
if candidate.SafeLabel != "download.tar" {
|
||||
t.Fatalf("SafeLabel = %q, want download.tar", candidate.SafeLabel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourceRedactsURLHeadersAndBodyFromErrors(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("X-Artifact-Secret", "header-secret")
|
||||
_, _ = writer.Write([]byte("response-body-secret"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
dependencies := defaultRemoteSourceDependenciesForTest()
|
||||
dependencies.createTemp = func(_ string, pattern string) (*os.File, error) {
|
||||
return os.CreateTemp(tempDir, pattern)
|
||||
}
|
||||
rawURL := server.URL + "/download?token=query-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderUnsupported) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want unsupported archive", err)
|
||||
}
|
||||
assertRemoteSourceTempDirEmpty(t, tempDir)
|
||||
assertRemoteSourceErrorRedacted(
|
||||
t,
|
||||
err,
|
||||
rawURL,
|
||||
"query-secret",
|
||||
"header-secret",
|
||||
"response-body-secret",
|
||||
"token=",
|
||||
)
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourceAllowsFiveRedirectsOnly(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
var requestCount atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestCount.Add(1)
|
||||
redirectNumber, _ := strconv.Atoi(strings.TrimPrefix(request.URL.Path, "/"))
|
||||
if redirectNumber < remoteSourceMaxRedirects+1 {
|
||||
writer.Header().Set("Location", "/"+strconv.Itoa(redirectNumber+1))
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
dependencies := mappedRemoteSourceDependencies(server.Listener.Addr().String(), staticPublicRemoteSourceResolver())
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: "http://artifact.example/0",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderRedirectLimit) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want redirect limit", err)
|
||||
}
|
||||
if requestCount.Load() != remoteSourceMaxRedirects+1 {
|
||||
t.Fatalf("request count = %d, want initial plus five redirects", requestCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func staticPublicRemoteSourceResolver() remoteSourceResolver {
|
||||
return remoteSourceResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func defaultRemoteSourceDependenciesForTest() remoteSourceDependencies {
|
||||
dialer := &net.Dialer{}
|
||||
return remoteSourceDependencies{
|
||||
resolver: net.DefaultResolver,
|
||||
dialContext: dialer.DialContext,
|
||||
createTemp: os.CreateTemp,
|
||||
}
|
||||
}
|
||||
|
||||
func mappedRemoteSourceDependencies(targetAddress string, resolver remoteSourceResolver) remoteSourceDependencies {
|
||||
dialer := &net.Dialer{}
|
||||
return remoteSourceDependencies{
|
||||
resolver: resolver,
|
||||
dialContext: func(ctx context.Context, network string, _ string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, targetAddress)
|
||||
},
|
||||
createTemp: os.CreateTemp,
|
||||
}
|
||||
}
|
||||
|
||||
func makeRemoteSourceZIP(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
archive := zip.NewWriter(&buffer)
|
||||
file, err := archive.Create("index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("zip.Create() error = %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte("<h1>OpenFlare</h1>")); err != nil {
|
||||
t.Fatalf("zip entry Write() error = %v", err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatalf("zip.Close() error = %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func assertRemoteSourceTempDirEmpty(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir() error = %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("temporary directory contains %d leaked files", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func assertRemoteSourceErrorRedacted(t *testing.T, err error, sensitiveValues ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, sensitiveValue := range sensitiveValues {
|
||||
if sensitiveValue != "" && strings.Contains(message, sensitiveValue) {
|
||||
t.Fatalf("error %q contains sensitive value %q", message, sensitiveValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
pagesSourceCheckLeaseDuration = 2 * time.Minute
|
||||
pagesSourceSyncLeaseDuration = 15 * time.Minute
|
||||
sourceLeaseTokenBytes = 32
|
||||
sourceRuntimeErrorMaxBytes = 512
|
||||
sourceRevisionHexLength = 64
|
||||
|
||||
sourceColumnAutoUpdateEnabled = "auto_update_enabled"
|
||||
sourceColumnConfigVersion = "config_version"
|
||||
sourceRuntimeColumnSyncStatus = "sync_status"
|
||||
sourceRuntimeColumnLastError = "last_error"
|
||||
sourceRuntimeColumnLastCheckedAt = "last_checked_at"
|
||||
sourceRuntimeColumnNextCheckAt = "next_check_at"
|
||||
sourceRuntimeColumnLeaseToken = "lease_token"
|
||||
sourceRuntimeColumnLeaseExpiresAt = "lease_expires_at"
|
||||
pagesDeploymentColumnStatus = "status"
|
||||
)
|
||||
|
||||
type sourceLeaseOutcome string
|
||||
|
||||
const (
|
||||
sourceLeaseAcquired sourceLeaseOutcome = "acquired"
|
||||
sourceLeaseBusy sourceLeaseOutcome = "busy"
|
||||
sourceLeaseStale sourceLeaseOutcome = "stale"
|
||||
)
|
||||
|
||||
// sourceExecutionSnapshot captures every mutable value that can affect archive
|
||||
// validation or the atomic activation decision. The queued payload deliberately
|
||||
// does not carry project content configuration.
|
||||
type sourceExecutionSnapshot struct {
|
||||
ProjectID uint
|
||||
SourceID uint
|
||||
SourceConfigVersion int
|
||||
ContentConfigVersion int
|
||||
SourceType string
|
||||
SourceIdentity string
|
||||
RemoteURL string
|
||||
AllowInsecure bool
|
||||
GitHubRepository string
|
||||
ReleaseSelector string
|
||||
ReleaseTag string
|
||||
AssetName string
|
||||
AutoUpdateEnabled bool
|
||||
CheckIntervalMinutes int
|
||||
ETag string
|
||||
LastSeenRevision string
|
||||
LastSeenDetail string
|
||||
LastAppliedRevision string
|
||||
LastAppliedDetail string
|
||||
RootDir string
|
||||
EntryFile string
|
||||
LeaseToken string
|
||||
LeaseExpiresAt time.Time
|
||||
}
|
||||
|
||||
func acquireSourceLease(
|
||||
ctx context.Context,
|
||||
sourceID uint,
|
||||
expectedConfigVersion int,
|
||||
action string,
|
||||
) (*sourceExecutionSnapshot, sourceLeaseOutcome, error) {
|
||||
leaseDuration, status, err := sourceLeaseParameters(action)
|
||||
if err != nil {
|
||||
return nil, sourceLeaseStale, err
|
||||
}
|
||||
token, err := newSourceLeaseToken()
|
||||
if err != nil {
|
||||
return nil, sourceLeaseStale, err
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(leaseDuration)
|
||||
|
||||
rows, err := repository.TryAcquirePagesSourceRuntimeLease(ctx, sourceID, expectedConfigVersion, now, map[string]any{
|
||||
sourceRuntimeColumnLeaseToken: token,
|
||||
sourceRuntimeColumnLeaseExpiresAt: expiresAt,
|
||||
sourceRuntimeColumnSyncStatus: status,
|
||||
sourceRuntimeColumnLastError: "",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, sourceLeaseStale, err
|
||||
}
|
||||
if rows == 0 {
|
||||
outcome, inspectErr := inspectSourceLeaseMiss(ctx, sourceID, expectedConfigVersion, now)
|
||||
return nil, outcome, inspectErr
|
||||
}
|
||||
|
||||
snapshot, err := loadSourceExecutionSnapshot(ctx, sourceID, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSourceLeaseSnapshotStale) {
|
||||
return nil, sourceLeaseStale, nil
|
||||
}
|
||||
return nil, sourceLeaseStale, err
|
||||
}
|
||||
return snapshot, sourceLeaseAcquired, nil
|
||||
}
|
||||
|
||||
var errSourceLeaseSnapshotStale = errors.New("source lease snapshot stale")
|
||||
|
||||
func loadSourceExecutionSnapshot(
|
||||
ctx context.Context,
|
||||
sourceID uint,
|
||||
token string,
|
||||
) (*sourceExecutionSnapshot, error) {
|
||||
var snapshot sourceExecutionSnapshot
|
||||
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
||||
source, err := repository.GetPagesProjectSourceByIDTx(tx, sourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errSourceLeaseSnapshotStale
|
||||
}
|
||||
return err
|
||||
}
|
||||
project, err := repository.LockPagesProjectByIDTx(tx, source.ProjectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errSourceLeaseSnapshotStale
|
||||
}
|
||||
return err
|
||||
}
|
||||
source, err = repository.LockPagesProjectSourceByIDTx(tx, sourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errSourceLeaseSnapshotStale
|
||||
}
|
||||
return err
|
||||
}
|
||||
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, source.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errSourceLeaseSnapshotStale
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 必须在 runtime 行锁拿到后重新取时间,避免锁等待跨过
|
||||
// lease expiry 时仍使用事务开始前的旧时间继续执行。
|
||||
now := time.Now()
|
||||
if runtime.LeaseToken != token || runtime.LeaseExpiresAt == nil || !runtime.LeaseExpiresAt.After(now) {
|
||||
return errSourceLeaseSnapshotStale
|
||||
}
|
||||
snapshot = sourceExecutionSnapshot{
|
||||
ProjectID: project.ID,
|
||||
SourceID: source.ID,
|
||||
SourceConfigVersion: source.ConfigVersion,
|
||||
ContentConfigVersion: project.ContentConfigVersion,
|
||||
SourceType: source.SourceType,
|
||||
SourceIdentity: source.SourceIdentity,
|
||||
RemoteURL: source.RemoteURL,
|
||||
AllowInsecure: source.AllowInsecure,
|
||||
GitHubRepository: source.GitHubRepository,
|
||||
ReleaseSelector: source.ReleaseSelector,
|
||||
ReleaseTag: source.ReleaseTag,
|
||||
AssetName: source.AssetName,
|
||||
AutoUpdateEnabled: source.AutoUpdateEnabled,
|
||||
CheckIntervalMinutes: source.CheckIntervalMinutes,
|
||||
ETag: runtime.ETag,
|
||||
LastSeenRevision: runtime.LastSeenRevision,
|
||||
LastSeenDetail: runtime.LastSeenDetail,
|
||||
LastAppliedRevision: runtime.LastAppliedRevision,
|
||||
LastAppliedDetail: runtime.LastAppliedDetail,
|
||||
RootDir: project.RootDir,
|
||||
EntryFile: project.EntryFile,
|
||||
LeaseToken: token,
|
||||
LeaseExpiresAt: *runtime.LeaseExpiresAt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
func inspectSourceLeaseMiss(
|
||||
ctx context.Context,
|
||||
sourceID uint,
|
||||
expectedConfigVersion int,
|
||||
now time.Time,
|
||||
) (sourceLeaseOutcome, error) {
|
||||
source, err := repository.GetPagesProjectSourceByID(ctx, sourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return sourceLeaseStale, nil
|
||||
}
|
||||
return sourceLeaseStale, err
|
||||
}
|
||||
if source.ConfigVersion != expectedConfigVersion {
|
||||
return sourceLeaseStale, nil
|
||||
}
|
||||
runtime, err := repository.GetPagesProjectSourceRuntimeBySourceID(ctx, sourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return sourceLeaseStale, nil
|
||||
}
|
||||
return sourceLeaseStale, err
|
||||
}
|
||||
if runtime.LeaseExpiresAt != nil && runtime.LeaseExpiresAt.After(now) {
|
||||
return sourceLeaseBusy, nil
|
||||
}
|
||||
return sourceLeaseStale, nil
|
||||
}
|
||||
|
||||
func sourceLeaseParameters(action string) (time.Duration, string, error) {
|
||||
switch action {
|
||||
case sourceActionCheck:
|
||||
return pagesSourceCheckLeaseDuration, pagesSourceStatusChecking, nil
|
||||
case sourceActionSync:
|
||||
return pagesSourceSyncLeaseDuration, pagesSourceStatusSyncing, nil
|
||||
default:
|
||||
return 0, "", errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func newSourceLeaseToken() (string, error) {
|
||||
value := make([]byte, sourceLeaseTokenBytes)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func renewSourceLease(ctx context.Context, snapshot *sourceExecutionSnapshot, duration time.Duration) (bool, error) {
|
||||
if snapshot == nil || snapshot.SourceID == 0 || snapshot.LeaseToken == "" || duration <= 0 {
|
||||
return false, errors.New(errPagesSourceLeaseLost)
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(duration)
|
||||
rows, err := repository.RenewPagesSourceRuntimeLease(ctx, snapshot.SourceID, snapshot.LeaseToken, now, expiresAt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if rows == 0 {
|
||||
return false, nil
|
||||
}
|
||||
snapshot.LeaseExpiresAt = expiresAt
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func failSourceLease(ctx context.Context, snapshot *sourceExecutionSnapshot, message string) error {
|
||||
if snapshot == nil || snapshot.SourceID == 0 || snapshot.LeaseToken == "" {
|
||||
return nil
|
||||
}
|
||||
message = safeSourceRuntimeError(message)
|
||||
now := time.Now()
|
||||
_, err := repository.UpdatePagesSourceRuntimeByActiveLease(
|
||||
ctx,
|
||||
snapshot.SourceID,
|
||||
snapshot.LeaseToken,
|
||||
now,
|
||||
map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||
sourceRuntimeColumnLastError: message,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func safeSourceRuntimeError(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return errPagesSourceSyncFailed
|
||||
}
|
||||
if len(message) > sourceRuntimeErrorMaxBytes {
|
||||
message = message[:sourceRuntimeErrorMaxBytes]
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func sourceLeaseIsBusy(ctx context.Context, sourceID uint) (bool, error) {
|
||||
runtime, err := repository.GetPagesProjectSourceRuntimeBySourceID(ctx, sourceID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return runtime.LeaseExpiresAt != nil && runtime.LeaseExpiresAt.After(time.Now()), nil
|
||||
}
|
||||
|
||||
// recoverExpiredSourceLease clears one exact expired lease owner. Matching the
|
||||
// token, observed expiry and status prevents a scanner from overwriting a
|
||||
// worker that renewed or was replaced after the candidate query.
|
||||
func recoverExpiredSourceLease(
|
||||
ctx context.Context,
|
||||
sourceID uint,
|
||||
token string,
|
||||
expiresAt time.Time,
|
||||
status string,
|
||||
now time.Time,
|
||||
nextCheckAt *time.Time,
|
||||
) (bool, error) {
|
||||
if sourceID == 0 || token == "" ||
|
||||
(status != pagesSourceStatusChecking && status != pagesSourceStatusSyncing) {
|
||||
return false, nil
|
||||
}
|
||||
updates := map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusFailed,
|
||||
sourceRuntimeColumnLastError: errPagesSourceLeaseExpired,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
sourceRuntimeColumnNextCheckAt: nextCheckAt,
|
||||
}
|
||||
rows, err := repository.RecoverExpiredPagesSourceRuntimeLease(
|
||||
ctx, sourceID, token, expiresAt, status, now, updates,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rows == 1, nil
|
||||
}
|
||||
|
||||
// fenceAndNormalizeRuntime invalidates in-flight work while preserving safe
|
||||
// seen/applied cursors. The caller must already hold the source row lock.
|
||||
func fenceAndNormalizeRuntime(tx *gorm.DB, sourceID uint) error {
|
||||
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, sourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return repository.UpdatePagesProjectSourceRuntimeTx(tx, runtime, map[string]any{
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
sourceRuntimeColumnSyncStatus: normalizedSourceRuntimeStatus(runtime),
|
||||
})
|
||||
}
|
||||
|
||||
func sourceHasSameReleaseReplacement(runtime *model.PagesProjectSourceRuntime) bool {
|
||||
if runtime == nil || runtime.LastSeenRevision == "" || runtime.LastSeenRevision == runtime.LastAppliedRevision {
|
||||
return false
|
||||
}
|
||||
seen := sourceDetail{}
|
||||
applied := sourceDetail{}
|
||||
if unmarshalSourceDetail(runtime.LastSeenDetail, &seen) != nil ||
|
||||
unmarshalSourceDetail(runtime.LastAppliedDetail, &applied) != nil {
|
||||
return false
|
||||
}
|
||||
return seen.ReleaseID != "" && seen.ReleaseID == applied.ReleaseID
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
)
|
||||
|
||||
func TestSourceLeaseHeartbeatRenewsAndCancelsOnOwnershipLoss(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "lease-heartbeat")
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
false,
|
||||
)
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil || outcome != sourceLeaseAcquired || snapshot == nil {
|
||||
t.Fatalf("acquireSourceLease(heartbeat) = (%+v, %q, %v), want acquired", snapshot, outcome, err)
|
||||
}
|
||||
|
||||
workCtx, heartbeat, err := startSourceLeaseHeartbeat(ctx, snapshot, 500*time.Millisecond, 20*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("startSourceLeaseHeartbeat() error = %v, want nil", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = heartbeat.stop() })
|
||||
|
||||
var initial model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&initial).Error; err != nil {
|
||||
t.Fatalf("load initial heartbeat runtime error = %v, want nil", err)
|
||||
}
|
||||
if initial.LeaseExpiresAt == nil {
|
||||
t.Fatal("initial heartbeat expiry = nil, want non-nil")
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
var renewedRuntime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&renewedRuntime).Error; err != nil {
|
||||
t.Fatalf("load renewed heartbeat runtime error = %v, want nil", err)
|
||||
}
|
||||
if renewedRuntime.LeaseExpiresAt != nil && renewedRuntime.LeaseExpiresAt.After(*initial.LeaseExpiresAt) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("heartbeat did not extend lease before deadline")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", source.ID).
|
||||
Update("lease_token", "replacement-owner").Error; err != nil {
|
||||
t.Fatalf("replace heartbeat lease owner error = %v, want nil", err)
|
||||
}
|
||||
select {
|
||||
case <-workCtx.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat work context was not canceled after ownership loss")
|
||||
}
|
||||
if err := heartbeat.stop(); !errors.Is(err, errSourceLeaseHeartbeatLost) {
|
||||
t.Fatalf("heartbeat.stop() error = %v, want %v", err, errSourceLeaseHeartbeatLost)
|
||||
}
|
||||
if err := heartbeat.stop(); !errors.Is(err, errSourceLeaseHeartbeatLost) {
|
||||
t.Fatalf("heartbeat.stop() second error = %v, want stable %v", err, errSourceLeaseHeartbeatLost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireSourceLeaseConcurrentOnlyOneOwner(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "lease-concurrent")
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
false,
|
||||
)
|
||||
type leaseResult struct {
|
||||
snapshot *sourceExecutionSnapshot
|
||||
outcome sourceLeaseOutcome
|
||||
err error
|
||||
}
|
||||
results := make(chan leaseResult, 2)
|
||||
var workers sync.WaitGroup
|
||||
workers.Add(2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
results <- leaseResult{snapshot: snapshot, outcome: outcome, err: err}
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
close(results)
|
||||
|
||||
acquired := 0
|
||||
busy := 0
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Errorf("acquireSourceLease(concurrent) error = %v, want nil", result.err)
|
||||
continue
|
||||
}
|
||||
switch result.outcome {
|
||||
case sourceLeaseAcquired:
|
||||
acquired++
|
||||
if result.snapshot == nil || result.snapshot.LeaseToken == "" {
|
||||
t.Errorf("acquireSourceLease(concurrent acquired) snapshot = %+v, want token-bearing snapshot", result.snapshot)
|
||||
}
|
||||
case sourceLeaseBusy:
|
||||
busy++
|
||||
if result.snapshot != nil {
|
||||
t.Errorf("acquireSourceLease(concurrent busy) snapshot = %+v, want nil", result.snapshot)
|
||||
}
|
||||
default:
|
||||
t.Errorf("acquireSourceLease(concurrent) outcome = %q, want acquired or busy", result.outcome)
|
||||
}
|
||||
}
|
||||
if acquired != 1 || busy != 1 {
|
||||
t.Errorf("concurrent lease outcomes = acquired:%d busy:%d, want 1 and 1", acquired, busy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireSourceLeaseMutualExclusionExpiryAndTerminalOwnership(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "lease-cas")
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
false,
|
||||
)
|
||||
|
||||
first, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil {
|
||||
t.Fatalf("acquireSourceLease(first) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := outcome, sourceLeaseAcquired; got != want {
|
||||
t.Fatalf("acquireSourceLease(first) outcome = %q, want %q", got, want)
|
||||
}
|
||||
if first == nil || first.LeaseToken == "" {
|
||||
t.Fatalf("acquireSourceLease(first) snapshot = %+v, want token-bearing snapshot", first)
|
||||
}
|
||||
|
||||
second, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil {
|
||||
t.Fatalf("acquireSourceLease(duplicate) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := outcome, sourceLeaseBusy; got != want {
|
||||
t.Errorf("acquireSourceLease(duplicate) outcome = %q, want %q", got, want)
|
||||
}
|
||||
if second != nil {
|
||||
t.Errorf("acquireSourceLease(duplicate) snapshot = %+v, want nil", second)
|
||||
}
|
||||
|
||||
past := time.Now().Add(-time.Second)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", source.ID).
|
||||
Update("lease_expires_at", &past).Error; err != nil {
|
||||
t.Fatalf("expire first lease error = %v, want nil", err)
|
||||
}
|
||||
takeover, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil {
|
||||
t.Fatalf("acquireSourceLease(takeover) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := outcome, sourceLeaseAcquired; got != want {
|
||||
t.Fatalf("acquireSourceLease(takeover) outcome = %q, want %q", got, want)
|
||||
}
|
||||
if takeover == nil {
|
||||
t.Fatal("acquireSourceLease(takeover) snapshot = nil, want non-nil")
|
||||
}
|
||||
if takeover.LeaseToken == "" || takeover.LeaseToken == first.LeaseToken {
|
||||
t.Fatalf("takeover LeaseToken = %q, want non-empty token distinct from %q", takeover.LeaseToken, first.LeaseToken)
|
||||
}
|
||||
|
||||
renewed, err := renewSourceLease(ctx, first, pagesSourceSyncLeaseDuration)
|
||||
if err != nil {
|
||||
t.Fatalf("renewSourceLease(expired owner) error = %v, want nil", err)
|
||||
}
|
||||
if renewed {
|
||||
t.Error("renewSourceLease(expired owner) = true, want false")
|
||||
}
|
||||
if err := failSourceLease(ctx, first, "stale worker must not win"); err != nil {
|
||||
t.Fatalf("failSourceLease(expired owner) error = %v, want nil", err)
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
|
||||
t.Fatalf("load runtime after takeover error = %v, want nil", err)
|
||||
}
|
||||
if got, want := runtime.LeaseToken, takeover.LeaseToken; got != want {
|
||||
t.Errorf("runtime LeaseToken after stale terminal write = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := runtime.SyncStatus, pagesSourceStatusSyncing; got != want {
|
||||
t.Errorf("runtime SyncStatus after stale terminal write = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
renewed, err = renewSourceLease(ctx, takeover, pagesSourceSyncLeaseDuration)
|
||||
if err != nil {
|
||||
t.Fatalf("renewSourceLease(current owner) error = %v, want nil", err)
|
||||
}
|
||||
if !renewed {
|
||||
t.Error("renewSourceLease(current owner) = false, want true")
|
||||
}
|
||||
if err := failSourceLease(ctx, takeover, errPagesSourceSyncFailed); err != nil {
|
||||
t.Fatalf("failSourceLease(current owner) error = %v, want nil", err)
|
||||
}
|
||||
var failedRuntime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&failedRuntime).Error; err != nil {
|
||||
t.Fatalf("load failed runtime error = %v, want nil", err)
|
||||
}
|
||||
if got, want := failedRuntime.SyncStatus, pagesSourceStatusFailed; got != want {
|
||||
t.Errorf("failed runtime SyncStatus = %q, want %q", got, want)
|
||||
}
|
||||
if failedRuntime.LeaseToken != "" || failedRuntime.LeaseExpiresAt != nil {
|
||||
t.Errorf("failed runtime lease = (%q, %v), want cleared", failedRuntime.LeaseToken, failedRuntime.LeaseExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceConfigAndProjectContentChangesFenceLease(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "lease-fence")
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=first",
|
||||
false,
|
||||
)
|
||||
|
||||
configSnapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil || outcome != sourceLeaseAcquired {
|
||||
t.Fatalf("acquireSourceLease(config fence) = (%+v, %q, %v), want acquired", configSnapshot, outcome, err)
|
||||
}
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: "https://example.com/site.zip?token=second",
|
||||
AllowInsecure: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(config fence) error = %v, want nil", err)
|
||||
}
|
||||
renewed, err := renewSourceLease(ctx, configSnapshot, pagesSourceSyncLeaseDuration)
|
||||
if err != nil {
|
||||
t.Fatalf("renewSourceLease(after source update) error = %v, want nil", err)
|
||||
}
|
||||
if renewed {
|
||||
t.Error("renewSourceLease(after source update) = true, want false")
|
||||
}
|
||||
var updatedSource model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("id = ?", source.ID).First(&updatedSource).Error; err != nil {
|
||||
t.Fatalf("load updated source error = %v, want nil", err)
|
||||
}
|
||||
if got, want := updatedSource.ConfigVersion, source.ConfigVersion+1; got != want {
|
||||
t.Errorf("updated source ConfigVersion = %d, want %d", got, want)
|
||||
}
|
||||
if snapshot, staleOutcome, staleErr := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync); staleErr != nil || staleOutcome != sourceLeaseStale || snapshot != nil {
|
||||
t.Errorf("acquireSourceLease(old config) = (%+v, %q, %v), want (nil, %q, nil)", snapshot, staleOutcome, staleErr, sourceLeaseStale)
|
||||
}
|
||||
|
||||
contentSnapshot, outcome, err := acquireSourceLease(ctx, source.ID, updatedSource.ConfigVersion, sourceActionSync)
|
||||
if err != nil || outcome != sourceLeaseAcquired {
|
||||
t.Fatalf("acquireSourceLease(content fence) = (%+v, %q, %v), want acquired", contentSnapshot, outcome, err)
|
||||
}
|
||||
if _, err := UpdateProject(ctx, project.ID, Input{
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Enabled: true,
|
||||
RootDir: "dist",
|
||||
EntryFile: "index.html",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateProject(content fence) error = %v, want nil", err)
|
||||
}
|
||||
renewed, err = renewSourceLease(ctx, contentSnapshot, pagesSourceSyncLeaseDuration)
|
||||
if err != nil {
|
||||
t.Fatalf("renewSourceLease(after content update) error = %v, want nil", err)
|
||||
}
|
||||
if renewed {
|
||||
t.Error("renewSourceLease(after content update) = true, want false")
|
||||
}
|
||||
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := storedProject.ContentConfigVersion, project.ContentConfigVersion+1; got != want {
|
||||
t.Errorf("ContentConfigVersion after RootDir update = %d, want %d", got, want)
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
|
||||
t.Fatalf("load fenced runtime error = %v, want nil", err)
|
||||
}
|
||||
if runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
|
||||
t.Errorf("content-fenced runtime lease = (%q, %v), want cleared", runtime.LeaseToken, runtime.LeaseExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRuntimeUsesOnlySixDocumentedStates(t *testing.T) {
|
||||
states := []string{
|
||||
pagesSourceStatusIdle,
|
||||
pagesSourceStatusChecking,
|
||||
pagesSourceStatusUpdateAvailable,
|
||||
pagesSourceStatusSyncing,
|
||||
pagesSourceStatusFailed,
|
||||
pagesSourceStatusAttention,
|
||||
}
|
||||
seen := make(map[string]struct{}, len(states))
|
||||
for _, state := range states {
|
||||
if strings.TrimSpace(state) == "" {
|
||||
t.Errorf("documented source state = %q, want non-empty", state)
|
||||
}
|
||||
if _, exists := seen[state]; exists {
|
||||
t.Errorf("documented source state %q is duplicated", state)
|
||||
}
|
||||
seen[state] = struct{}{}
|
||||
}
|
||||
if got, want := len(seen), 6; got != want {
|
||||
t.Errorf("unique source states = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
updateRuntime := &model.PagesProjectSourceRuntime{
|
||||
LastSeenRevision: strings.Repeat("a", 64),
|
||||
LastAppliedRevision: strings.Repeat("b", 64),
|
||||
LastSeenDetail: `{"release_id":"new"}`,
|
||||
LastAppliedDetail: `{"release_id":"old"}`,
|
||||
}
|
||||
if got, want := normalizedSourceRuntimeStatus(updateRuntime), pagesSourceStatusUpdateAvailable; got != want {
|
||||
t.Errorf("normalizedSourceRuntimeStatus(update) = %q, want %q", got, want)
|
||||
}
|
||||
attentionRuntime := &model.PagesProjectSourceRuntime{
|
||||
LastSeenRevision: strings.Repeat("a", 64),
|
||||
LastAppliedRevision: strings.Repeat("b", 64),
|
||||
LastSeenDetail: `{"release_id":"same"}`,
|
||||
LastAppliedDetail: `{"release_id":"same"}`,
|
||||
}
|
||||
if got, want := normalizedSourceRuntimeStatus(attentionRuntime), pagesSourceStatusAttention; got != want {
|
||||
t.Errorf("normalizedSourceRuntimeStatus(attention) = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := normalizedSourceRuntimeStatus(&model.PagesProjectSourceRuntime{}), pagesSourceStatusIdle; got != want {
|
||||
t.Errorf("normalizedSourceRuntimeStatus(idle) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
"Wavelet/openflare/plugins/server/kernel/task"
|
||||
"Wavelet/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// PagesSourceScanTask is the private Asynq task type for the periodic scanner.
|
||||
PagesSourceScanTask = "openflare:pages_source_scan"
|
||||
// TaskTypePagesSourceScan is the internal task meta type seeded in w_schedules.
|
||||
TaskTypePagesSourceScan = "of_pages_source_scan"
|
||||
|
||||
pagesSourceScanBatchSize = 20
|
||||
)
|
||||
|
||||
// PagesSourceScanMeta describes the periodic Pages source scanner schedule.
|
||||
var PagesSourceScanMeta = task.TaskMeta{
|
||||
Type: TaskTypePagesSourceScan,
|
||||
AsynqTask: PagesSourceScanTask,
|
||||
Name: "OpenFlare Pages 部署源扫描",
|
||||
Description: "补偿孤儿部署包、恢复过期执行权并串行检查到期的 GitHub latest 部署源",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 0,
|
||||
Queue: task.QueueDefault,
|
||||
Retryable: false,
|
||||
}
|
||||
|
||||
type pagesSourceScanPayload struct{}
|
||||
|
||||
type pagesSourceScanSummary struct {
|
||||
ExpiredCandidates int `json:"expired_candidates"`
|
||||
RecoveredLeases int `json:"recovered_leases"`
|
||||
OrphanCleanup PagesOrphanCleanupSummary `json:"orphan_cleanup"`
|
||||
DueSources int `json:"due_sources"`
|
||||
SelectedSources int `json:"selected_sources"`
|
||||
CheckedSources int `json:"checked_sources"`
|
||||
UpdatesFound int `json:"updates_found"`
|
||||
AttentionSources int `json:"attention_sources"`
|
||||
DispatchedSyncs int `json:"dispatched_syncs"`
|
||||
FailedDispatches int `json:"failed_dispatches"`
|
||||
BusySources int `json:"busy_sources"`
|
||||
StaleSources int `json:"stale_sources"`
|
||||
FailedSources int `json:"failed_sources"`
|
||||
Backlog int `json:"backlog"`
|
||||
ProviderBackoffs []pagesSourceProviderBackoff `json:"provider_backoffs,omitempty"`
|
||||
}
|
||||
|
||||
type pagesSourceProviderBackoff struct {
|
||||
SourceID uint `json:"source_id"`
|
||||
StatusCode int `json:"status_code"`
|
||||
RetryAt string `json:"retry_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
pagesSourceScanNow = time.Now
|
||||
reconcilePagesSourceOrphans = ReconcilePagesOrphanUploads
|
||||
dispatchPagesSourceAutoSync = func(
|
||||
ctx context.Context,
|
||||
source model.PagesProjectSource,
|
||||
targetRevision string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
return dispatchSourceActionSnapshotWithTrigger(
|
||||
ctx,
|
||||
source,
|
||||
sourceActionSync,
|
||||
pagesSourceCreatedBySystem,
|
||||
pagesSourceTriggerScheduledAutoUpdate,
|
||||
targetRevision,
|
||||
"",
|
||||
"system",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// SourceScanHandler serializes provider checks inside one scheduled task. A
|
||||
// source-level lease still permits overlapping scanner executions safely.
|
||||
type SourceScanHandler struct{}
|
||||
|
||||
// ValidatePayload accepts only an empty object; the scanner has no user input.
|
||||
func (handler *SourceScanHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
payload = []byte("{}")
|
||||
}
|
||||
var input pagesSourceScanPayload
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
||||
// Execute recovers expired leases and checks at most 20 due latest sources in
|
||||
// stable order. Provider and dispatch failures are isolated per source.
|
||||
func (handler *SourceScanHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
if _, err := handler.ValidatePayload(payload); err != nil {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
|
||||
now := pagesSourceScanNow()
|
||||
summary := pagesSourceScanSummary{}
|
||||
if err := recoverExpiredPagesSourceLeases(ctx, now, &summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orphanSummary, err := reconcilePagesSourceOrphans(ctx, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.OrphanCleanup = orphanSummary
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[cleanup] orphan 候选=%d,已补偿=%d,仍被引用=%d,lease busy=%d,非法 marker=%d,跳过=%d,失败=%d",
|
||||
orphanSummary.Candidates,
|
||||
orphanSummary.Reconciled,
|
||||
orphanSummary.Referenced,
|
||||
orphanSummary.LeaseBusy,
|
||||
orphanSummary.InvalidMarker,
|
||||
orphanSummary.Skipped,
|
||||
orphanSummary.Failed,
|
||||
)
|
||||
if err := scanDueGitHubSources(ctx, now, &summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detail, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Pages 部署源扫描完成:恢复 %d 个租约,补偿 %d 个孤儿记录,检查 %d 个来源,投递 %d 个自动更新,积压 %d 个",
|
||||
summary.RecoveredLeases,
|
||||
summary.OrphanCleanup.Reconciled,
|
||||
summary.CheckedSources,
|
||||
summary.DispatchedSyncs,
|
||||
summary.Backlog,
|
||||
)
|
||||
return &task.TaskResult{Message: message, Detail: string(detail)}, nil
|
||||
}
|
||||
|
||||
func recoverExpiredPagesSourceLeases(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
summary *pagesSourceScanSummary,
|
||||
) error {
|
||||
candidates, err := repository.ListExpiredPagesSourceLeaseCandidates(
|
||||
ctx,
|
||||
now,
|
||||
[]string{pagesSourceStatusChecking, pagesSourceStatusSyncing},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summary.ExpiredCandidates = len(candidates)
|
||||
for _, candidate := range candidates {
|
||||
var nextCheckAt *time.Time
|
||||
if candidate.SourceType == PagesSourceTypeGitHubRelease &&
|
||||
candidate.ReleaseSelector == githubReleaseSelectorLatest {
|
||||
next := nextGitHubCheckAt(now, candidate.SourceID, minimumCheckInterval)
|
||||
nextCheckAt = &next
|
||||
}
|
||||
recovered, recoverErr := recoverExpiredSourceLease(
|
||||
ctx,
|
||||
candidate.SourceID,
|
||||
candidate.LeaseToken,
|
||||
candidate.LeaseExpiresAt,
|
||||
candidate.SyncStatus,
|
||||
now,
|
||||
nextCheckAt,
|
||||
)
|
||||
if recoverErr != nil {
|
||||
summary.FailedSources++
|
||||
logger.WarnF(
|
||||
ctx,
|
||||
"[PagesSourceScan] recover expired lease failed: source_id=%d error=%v",
|
||||
candidate.SourceID,
|
||||
recoverErr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if recovered {
|
||||
summary.RecoveredLeases++
|
||||
task.AppendLog(ctx, "[recover] 已恢复过期来源租约:source_id=%d", candidate.SourceID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanDueGitHubSources(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
summary *pagesSourceScanSummary,
|
||||
) error {
|
||||
dueCount, err := repository.CountDueGitHubPagesSourceChecks(
|
||||
ctx, now, PagesSourceTypeGitHubRelease, githubReleaseSelectorLatest,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summary.DueSources = int(dueCount)
|
||||
|
||||
candidates, err := repository.ListDueGitHubPagesSourceChecks(
|
||||
ctx, now, PagesSourceTypeGitHubRelease, githubReleaseSelectorLatest, pagesSourceScanBatchSize,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summary.SelectedSources = len(candidates)
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[scan] 到期来源=%d,本批=%d",
|
||||
summary.DueSources,
|
||||
summary.SelectedSources,
|
||||
)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
scanOneDueGitHubSource(ctx, candidate, summary)
|
||||
}
|
||||
remainingDue, err := repository.CountDueGitHubPagesSourceChecks(
|
||||
ctx, now, PagesSourceTypeGitHubRelease, githubReleaseSelectorLatest,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summary.Backlog = int(remainingDue)
|
||||
task.AppendLog(ctx, "[scan] 本批处理后仍到期来源=%d", summary.Backlog)
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanOneDueGitHubSource(
|
||||
ctx context.Context,
|
||||
candidate model.PagesDueGitHubSourceCandidate,
|
||||
summary *pagesSourceScanSummary,
|
||||
) {
|
||||
snapshot, outcome, err := acquireSourceLease(
|
||||
ctx,
|
||||
candidate.SourceID,
|
||||
candidate.ConfigVersion,
|
||||
sourceActionCheck,
|
||||
)
|
||||
if err != nil {
|
||||
summary.FailedSources++
|
||||
logger.WarnF(ctx, "[PagesSourceScan] acquire check lease failed: source_id=%d error=%v", candidate.SourceID, err)
|
||||
return
|
||||
}
|
||||
switch outcome {
|
||||
case sourceLeaseBusy:
|
||||
summary.BusySources++
|
||||
task.AppendLog(ctx, "[check] 来源正在执行其它任务,跳过:source_id=%d", candidate.SourceID)
|
||||
return
|
||||
case sourceLeaseStale:
|
||||
summary.StaleSources++
|
||||
return
|
||||
case sourceLeaseAcquired:
|
||||
// 获取执行权成功,继续执行扫描。
|
||||
}
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease ||
|
||||
snapshot.ReleaseSelector != githubReleaseSelectorLatest {
|
||||
summary.StaleSources++
|
||||
if snapshot != nil {
|
||||
if finalizeErr := failSourceLease(ctx, snapshot, errPagesSourceActionStale); finalizeErr != nil {
|
||||
logger.WarnF(
|
||||
ctx,
|
||||
"[PagesSourceScan] finalize stale source failed: source_id=%d error=%v",
|
||||
snapshot.SourceID,
|
||||
finalizeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
checkResult, checkErr := checkGitHubSource(ctx, snapshot)
|
||||
if checkErr != nil {
|
||||
summary.FailedSources++
|
||||
recordPagesSourceProviderBackoff(ctx, candidate.SourceID, checkErr, summary)
|
||||
logger.WarnF(
|
||||
ctx,
|
||||
"[PagesSourceScan] source check failed: source_id=%d error=%s",
|
||||
candidate.SourceID,
|
||||
safeGitHubSourceError(checkErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
if checkResult == nil || checkResult.Stale {
|
||||
summary.StaleSources++
|
||||
return
|
||||
}
|
||||
handleCheckedGitHubSource(ctx, snapshot, checkResult, summary)
|
||||
}
|
||||
|
||||
func recordPagesSourceProviderBackoff(
|
||||
ctx context.Context,
|
||||
sourceID uint,
|
||||
checkErr error,
|
||||
summary *pagesSourceScanSummary,
|
||||
) {
|
||||
var domainError *githubSourceProviderDomainError
|
||||
if !errors.As(checkErr, &domainError) ||
|
||||
(domainError.statusCode != 403 && domainError.statusCode != 429) {
|
||||
return
|
||||
}
|
||||
|
||||
retryAt := domainError.retryAt
|
||||
runtime, err := repository.GetPagesProjectSourceRuntimeBySourceID(ctx, sourceID)
|
||||
if err != nil {
|
||||
logger.WarnF(ctx, "[PagesSourceScan] load provider backoff deadline failed: source_id=%d error=%v", sourceID, err)
|
||||
} else if runtime.NextCheckAt != nil {
|
||||
retryAt = runtime.NextCheckAt
|
||||
}
|
||||
|
||||
retryAtText := "unknown"
|
||||
if retryAt != nil {
|
||||
retryAtText = retryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
summary.ProviderBackoffs = append(summary.ProviderBackoffs, pagesSourceProviderBackoff{
|
||||
SourceID: sourceID, StatusCode: domainError.statusCode, RetryAt: retryAtText,
|
||||
})
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[check] GitHub provider 退避:source_id=%d status=%d retry_at=%s",
|
||||
sourceID,
|
||||
domainError.statusCode,
|
||||
retryAtText,
|
||||
)
|
||||
}
|
||||
|
||||
func handleCheckedGitHubSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
checkResult *githubCheckTaskResult,
|
||||
summary *pagesSourceScanSummary,
|
||||
) {
|
||||
summary.CheckedSources++
|
||||
switch checkResult.Status {
|
||||
case pagesSourceStatusUpdateAvailable:
|
||||
summary.UpdatesFound++
|
||||
case pagesSourceStatusAttention:
|
||||
summary.AttentionSources++
|
||||
}
|
||||
if !snapshot.AutoUpdateEnabled || checkResult.Status != pagesSourceStatusUpdateAvailable ||
|
||||
!validOptionalSourceRevision(checkResult.Revision) || checkResult.Revision == "" {
|
||||
return
|
||||
}
|
||||
|
||||
source := model.PagesProjectSource{
|
||||
ID: snapshot.SourceID,
|
||||
ProjectID: snapshot.ProjectID,
|
||||
ConfigVersion: snapshot.SourceConfigVersion,
|
||||
}
|
||||
receipt, dispatchErr := dispatchPagesSourceAutoSync(ctx, source, checkResult.Revision)
|
||||
if dispatchErr == nil {
|
||||
summary.DispatchedSyncs++
|
||||
if receipt != nil {
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[dispatch] 已投递自动更新:source_id=%d execution_id=%s revision=%s",
|
||||
snapshot.SourceID,
|
||||
receipt.ExecutionID,
|
||||
checkResult.Revision,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
summary.FailedSources++
|
||||
summary.FailedDispatches++
|
||||
logger.WarnF(
|
||||
ctx,
|
||||
"[PagesSourceScan] dispatch auto sync failed: source_id=%d revision=%s error=%v",
|
||||
snapshot.SourceID,
|
||||
checkResult.Revision,
|
||||
dispatchErr,
|
||||
)
|
||||
updated, recordErr := recordPagesSourceAutoDispatchFailure(
|
||||
ctx,
|
||||
snapshot,
|
||||
checkResult.Revision,
|
||||
checkResult.RetryAt,
|
||||
)
|
||||
if recordErr != nil {
|
||||
logger.WarnF(
|
||||
ctx,
|
||||
"[PagesSourceScan] record auto sync dispatch failure failed: source_id=%d error=%v",
|
||||
snapshot.SourceID,
|
||||
recordErr,
|
||||
)
|
||||
} else if !updated {
|
||||
summary.StaleSources++
|
||||
}
|
||||
}
|
||||
|
||||
func recordPagesSourceAutoDispatchFailure(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
revision string,
|
||||
retryAt *time.Time,
|
||||
) (bool, error) {
|
||||
if snapshot == nil || revision == "" {
|
||||
return false, nil
|
||||
}
|
||||
now := pagesSourceScanNow()
|
||||
next := nextGitHubCheckAt(now, snapshot.SourceID, minimumCheckInterval)
|
||||
if retryAt != nil && retryAt.After(next) {
|
||||
next = retryAt.In(now.Location())
|
||||
}
|
||||
rows, err := repository.RecordPagesSourceAutoDispatchFailure(
|
||||
ctx,
|
||||
snapshot.SourceID,
|
||||
snapshot.SourceConfigVersion,
|
||||
PagesSourceTypeGitHubRelease,
|
||||
githubReleaseSelectorLatest,
|
||||
revision,
|
||||
pagesSourceStatusUpdateAvailable,
|
||||
now,
|
||||
map[string]any{
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusUpdateAvailable,
|
||||
sourceRuntimeColumnLastError: errPagesSourceTaskDispatchFailed,
|
||||
sourceRuntimeColumnNextCheckAt: &next,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rows == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/githubrelease"
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type scannerDispatchedSync struct {
|
||||
SourceID uint
|
||||
Revision string
|
||||
}
|
||||
|
||||
func TestGitHubLatestAutoConfigPreservesIdentityAndRuntimeCursor(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "scanner-auto-config")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/scanner/auto-config",
|
||||
AutoUpdateEnabled: false,
|
||||
CheckIntervalMinutes: 60,
|
||||
})
|
||||
identity := source.SourceIdentity
|
||||
seenRevision := strings.Repeat("a", sourceRevisionHexLength)
|
||||
appliedRevision := strings.Repeat("b", sourceRevisionHexLength)
|
||||
future := time.Now().Add(time.Hour)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", source.ID).
|
||||
Updates(map[string]any{
|
||||
"etag": `"cursor-etag"`,
|
||||
"last_seen_revision": seenRevision,
|
||||
"last_seen_detail": `{"provider":"github","release_id":"2","asset_id":"2","tag":"v2","asset_name":"dist.zip"}`,
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": `{"provider":"github","release_id":"1","asset_id":"1","tag":"v1","asset_name":"dist.zip"}`,
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"last_error": "old error",
|
||||
"lease_token": "in-flight",
|
||||
"lease_expires_at": &future,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed runtime error = %v, want nil", err)
|
||||
}
|
||||
|
||||
input := SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/scanner/auto-config",
|
||||
AutoUpdateEnabled: true,
|
||||
CheckIntervalMinutes: 15,
|
||||
}
|
||||
if err := validateGitHubSourceInput(input); err != nil {
|
||||
t.Fatalf("validateGitHubSourceInput(auto latest) error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
changed, err := updateGitHubSourceTx(tx, project.ID, input)
|
||||
if err == nil && !changed {
|
||||
return errors.New("auto config update was treated as no-op")
|
||||
}
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("updateGitHubSourceTx(auto latest) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
updated, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if updated.SourceIdentity != identity || updated.ConfigVersion != source.ConfigVersion+1 {
|
||||
t.Errorf(
|
||||
"updated source = identity:%q version:%d, want identity:%q version:%d",
|
||||
updated.SourceIdentity,
|
||||
updated.ConfigVersion,
|
||||
identity,
|
||||
source.ConfigVersion+1,
|
||||
)
|
||||
}
|
||||
if !updated.AutoUpdateEnabled || updated.CheckIntervalMinutes != 15 {
|
||||
t.Errorf("updated auto config = enabled:%t interval:%d, want true/15", updated.AutoUpdateEnabled, updated.CheckIntervalMinutes)
|
||||
}
|
||||
if runtime.ETag != `"cursor-etag"` || runtime.LastSeenRevision != seenRevision ||
|
||||
runtime.LastAppliedRevision != appliedRevision {
|
||||
t.Errorf("runtime cursor changed after auto-only update: %+v", runtime)
|
||||
}
|
||||
if runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil || runtime.LastError != "" ||
|
||||
runtime.SyncStatus != pagesSourceStatusUpdateAvailable || runtime.NextCheckAt == nil {
|
||||
t.Errorf(
|
||||
"runtime fence = token:%q expiry:%v error:%q status:%q next:%v",
|
||||
runtime.LeaseToken,
|
||||
runtime.LeaseExpiresAt,
|
||||
runtime.LastError,
|
||||
runtime.SyncStatus,
|
||||
runtime.NextCheckAt,
|
||||
)
|
||||
}
|
||||
|
||||
tagConfig, err := buildGitHubSourceConfig(SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/scanner/auto-config",
|
||||
ReleaseSelector: githubReleaseSelectorTag,
|
||||
ReleaseTag: "v1",
|
||||
AutoUpdateEnabled: true,
|
||||
CheckIntervalMinutes: 60,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceConfig(tag) error = %v, want nil", err)
|
||||
}
|
||||
if tagConfig.AutoUpdate || tagConfig.CheckInterval != 0 {
|
||||
t.Errorf("tag config auto/interval = %t/%d, want false/0", tagConfig.AutoUpdate, tagConfig.CheckInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverExpiredPagesSourceLeaseUsesExactCASAndStableJitter(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "scanner-expired-lease")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/scanner/expired-lease",
|
||||
})
|
||||
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
||||
usePagesSourceScannerClock(t, now)
|
||||
expiredAt := now.Add(-time.Minute)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", source.ID).
|
||||
Updates(map[string]any{
|
||||
"sync_status": pagesSourceStatusChecking,
|
||||
"lease_token": "expired-owner",
|
||||
"lease_expires_at": &expiredAt,
|
||||
"next_check_at": &expiredAt,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed expired lease error = %v, want nil", err)
|
||||
}
|
||||
|
||||
summary := pagesSourceScanSummary{}
|
||||
if err := recoverExpiredPagesSourceLeases(ctx, now, &summary); err != nil {
|
||||
t.Fatalf("recoverExpiredPagesSourceLeases() error = %v, want nil", err)
|
||||
}
|
||||
if summary.ExpiredCandidates != 1 || summary.RecoveredLeases != 1 || summary.FailedSources != 0 {
|
||||
t.Errorf("recovery summary = %+v, want one recovered lease", summary)
|
||||
}
|
||||
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
||||
wantNext := nextGitHubCheckAt(now, source.ID, minimumCheckInterval)
|
||||
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceLeaseExpired ||
|
||||
runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil || runtime.NextCheckAt == nil ||
|
||||
runtime.NextCheckAt.Sub(wantNext) != 0 {
|
||||
t.Errorf(
|
||||
"recovered runtime = status:%q error:%q token:%q expiry:%v next:%v, want next %v",
|
||||
runtime.SyncStatus,
|
||||
runtime.LastError,
|
||||
runtime.LeaseToken,
|
||||
runtime.LeaseExpiresAt,
|
||||
runtime.NextCheckAt,
|
||||
wantNext,
|
||||
)
|
||||
}
|
||||
|
||||
renewedExpiry := now.Add(time.Minute)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", source.ID).
|
||||
Updates(map[string]any{
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"lease_token": "renewed-owner",
|
||||
"lease_expires_at": &renewedExpiry,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed renewed lease error = %v, want nil", err)
|
||||
}
|
||||
recovered, err := recoverExpiredSourceLease(
|
||||
ctx,
|
||||
source.ID,
|
||||
"renewed-owner",
|
||||
expiredAt,
|
||||
pagesSourceStatusSyncing,
|
||||
now,
|
||||
&wantNext,
|
||||
)
|
||||
if err != nil || recovered {
|
||||
t.Fatalf("recoverExpiredSourceLease(stale expiry) = %t, %v; want false, nil", recovered, err)
|
||||
}
|
||||
_, runtime = mustLoadPagesSource(t, ctx, project.ID)
|
||||
if runtime.LeaseToken != "renewed-owner" || runtime.LeaseExpiresAt == nil ||
|
||||
runtime.LeaseExpiresAt.Sub(renewedExpiry) != 0 || runtime.SyncStatus != pagesSourceStatusSyncing {
|
||||
t.Errorf("stale recovery overwrote renewed lease: %+v", runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAutoSyncPersistsExplicitDeploymentTrigger(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "scanner-scheduled-trigger")
|
||||
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/scanner/scheduled-trigger",
|
||||
AutoUpdateEnabled: true,
|
||||
})
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "scheduled-v1"})
|
||||
packageHash := sha256.Sum256(packageBytes)
|
||||
release := githubrelease.Release{ID: "scheduled-release", Tag: "v1"}
|
||||
asset := githubrelease.Asset{
|
||||
ID: "scheduled-asset", Name: defaultGitHubAssetName, State: "uploaded",
|
||||
UpdatedAt: time.Date(2026, 7, 19, 12, 30, 0, 0, time.UTC),
|
||||
}
|
||||
target, err := buildGitHubSourceTarget(release, asset, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGitHubSourceTarget() error = %v, want nil", err)
|
||||
}
|
||||
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(), "scheduled.zip")
|
||||
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile(scheduled package) error = %v", err)
|
||||
}
|
||||
return &githubrelease.DownloadResult{
|
||||
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil || outcome != sourceLeaseAcquired {
|
||||
t.Fatalf("acquireSourceLease() = %+v, %q, %v; want acquired", snapshot, outcome, err)
|
||||
}
|
||||
synced, err := syncGitHubSourceWithTrigger(
|
||||
ctx,
|
||||
snapshot,
|
||||
pagesSourceCreatedBySystem,
|
||||
target.Revision,
|
||||
"",
|
||||
pagesSourceTriggerScheduledAutoUpdate,
|
||||
)
|
||||
if err != nil || synced == nil || synced.Deployment == nil || synced.Stale {
|
||||
t.Fatalf("syncGitHubSourceWithTrigger() = %+v, %v; want active deployment", synced, err)
|
||||
}
|
||||
deployment, err := repository.GetPagesDeploymentByID(ctx, synced.Deployment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesDeploymentByID(%d) error = %v", synced.Deployment.ID, err)
|
||||
}
|
||||
if deployment.TriggerType != pagesSourceTriggerScheduledAutoUpdate ||
|
||||
deployment.CreatedBy != pagesSourceCreatedBySystem {
|
||||
t.Errorf(
|
||||
"scheduled provenance = trigger:%q actor:%q, want %q/%q",
|
||||
deployment.TriggerType,
|
||||
deployment.CreatedBy,
|
||||
pagesSourceTriggerScheduledAutoUpdate,
|
||||
pagesSourceCreatedBySystem,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesSourceScannerSerialBatchIsolation304AndActualBacklog(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
now := time.Now().Truncate(time.Second)
|
||||
usePagesSourceScannerClock(t, now)
|
||||
dueAt := now.Add(-time.Hour)
|
||||
|
||||
type fixture struct {
|
||||
source *model.PagesProjectSource
|
||||
runtime *model.PagesProjectSourceRuntime
|
||||
repository string
|
||||
}
|
||||
fixtures := make([]fixture, 0, 22)
|
||||
byRepository := make(map[string]int, 22)
|
||||
for index := 1; index <= 22; index++ {
|
||||
project := mustCreatePagesSourceProject(t, ctx, fmt.Sprintf("scanner-batch-%02d", index))
|
||||
repository := fmt.Sprintf("scanner/source-%02d", index)
|
||||
source, runtime := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/" + repository,
|
||||
AutoUpdateEnabled: index != 4,
|
||||
CheckIntervalMinutes: 60,
|
||||
})
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", source.ID).
|
||||
Update("next_check_at", &dueAt).Error; err != nil {
|
||||
t.Fatalf("mark source %d due error = %v, want nil", source.ID, err)
|
||||
}
|
||||
fixtures = append(fixtures, fixture{source: source, runtime: runtime, repository: repository})
|
||||
byRepository[repository] = index
|
||||
}
|
||||
|
||||
busyUntil := now.Add(time.Hour)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", fixtures[0].source.ID).
|
||||
Updates(map[string]any{
|
||||
"sync_status": pagesSourceStatusChecking,
|
||||
"lease_token": "busy-owner",
|
||||
"lease_expires_at": &busyUntil,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed busy source error = %v, want nil", err)
|
||||
}
|
||||
|
||||
stored304Revision := strings.Repeat("3", sourceRevisionHexLength)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", fixtures[2].source.ID).
|
||||
Updates(map[string]any{
|
||||
"etag": `"stored-etag"`,
|
||||
"last_seen_revision": stored304Revision,
|
||||
"last_seen_detail": `{"provider":"github","release_id":"release-3","asset_id":"3","tag":"v3","asset_name":"dist.zip"}`,
|
||||
"sync_status": pagesSourceStatusUpdateAvailable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed 304 cursor error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", fixtures[4].source.ID).
|
||||
Updates(map[string]any{
|
||||
"last_applied_revision": strings.Repeat("a", sourceRevisionHexLength),
|
||||
"last_applied_detail": `{"provider":"github","release_id":"shared-release","asset_id":"old","tag":"v5","asset_name":"dist.zip"}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed replacement cursor error = %v, want nil", err)
|
||||
}
|
||||
|
||||
retryAt := now.Add(2 * time.Hour)
|
||||
calledRepositories := make([]string, 0, pagesSourceScanBatchSize)
|
||||
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
||||
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
||||
index := byRepository[request.Repository]
|
||||
calledRepositories = append(calledRepositories, request.Repository)
|
||||
switch index {
|
||||
case 2:
|
||||
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
||||
Kind: githubrelease.ErrMetadata, StatusCode: 429, RetryAt: &retryAt,
|
||||
}
|
||||
case 3:
|
||||
if request.ETag != `"stored-etag"` {
|
||||
t.Errorf("304 source ETag = %q, want stored ETag", request.ETag)
|
||||
}
|
||||
return githubrelease.ResolveResult{NotModified: true, ETag: request.ETag}, nil
|
||||
default:
|
||||
releaseID := fmt.Sprintf("release-%d", index)
|
||||
if index == 5 {
|
||||
releaseID = "shared-release"
|
||||
}
|
||||
result := githubrelease.ResolveResult{
|
||||
ETag: fmt.Sprintf(`"etag-%d"`, index),
|
||||
Release: githubrelease.Release{ID: releaseID, Tag: fmt.Sprintf("v%d", index)},
|
||||
Asset: githubrelease.Asset{
|
||||
ID: fmt.Sprintf("asset-%d", index),
|
||||
Name: defaultGitHubAssetName,
|
||||
State: "uploaded",
|
||||
UpdatedAt: now.Add(time.Duration(index) * time.Minute),
|
||||
},
|
||||
}
|
||||
if index == 6 {
|
||||
result.RetryAt = &retryAt
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
},
|
||||
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
||||
t.Fatal("scanner downloaded an asset; want check-only behavior")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
|
||||
dispatched := make([]scannerDispatchedSync, 0, pagesSourceScanBatchSize)
|
||||
previousDispatch := dispatchPagesSourceAutoSync
|
||||
dispatchPagesSourceAutoSync = func(
|
||||
_ context.Context,
|
||||
source model.PagesProjectSource,
|
||||
revision string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
if source.ID == fixtures[5].source.ID {
|
||||
return nil, errors.New("injected dispatch failure")
|
||||
}
|
||||
dispatched = append(dispatched, scannerDispatchedSync{SourceID: source.ID, Revision: revision})
|
||||
return &SourceActionReceipt{ExecutionID: fmt.Sprintf("%d", source.ID), Action: sourceActionSync}, nil
|
||||
}
|
||||
t.Cleanup(func() { dispatchPagesSourceAutoSync = previousDispatch })
|
||||
|
||||
result, err := (&SourceScanHandler{}).Execute(ctx, []byte("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("SourceScanHandler.Execute() error = %v, want nil", err)
|
||||
}
|
||||
var summary pagesSourceScanSummary
|
||||
if err := json.Unmarshal([]byte(result.Detail), &summary); err != nil {
|
||||
t.Fatalf("json.Unmarshal(scan detail) error = %v, want nil", err)
|
||||
}
|
||||
if summary.DueSources != 22 || summary.SelectedSources != pagesSourceScanBatchSize ||
|
||||
summary.CheckedSources != 18 || summary.UpdatesFound != 17 || summary.AttentionSources != 1 ||
|
||||
summary.DispatchedSyncs != 15 || summary.FailedDispatches != 1 ||
|
||||
summary.BusySources != 1 || summary.FailedSources != 2 ||
|
||||
summary.Backlog != 3 {
|
||||
t.Errorf("scan summary = %+v, want due=22 selected=20 checked=18 updates=17 attention=1 dispatched=15 dispatch_failed=1 busy=1 failed=2 backlog=3", summary)
|
||||
}
|
||||
if len(summary.ProviderBackoffs) != 1 ||
|
||||
summary.ProviderBackoffs[0].SourceID != fixtures[1].source.ID ||
|
||||
summary.ProviderBackoffs[0].StatusCode != 429 ||
|
||||
summary.ProviderBackoffs[0].RetryAt != retryAt.UTC().Format(time.RFC3339) {
|
||||
t.Errorf("scan provider backoffs = %+v, want source=%d status=429 retry_at=%s", summary.ProviderBackoffs, fixtures[1].source.ID, retryAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
if len(calledRepositories) != 19 {
|
||||
t.Fatalf("Resolve calls = %d, want 19 (one busy source in selected batch)", len(calledRepositories))
|
||||
}
|
||||
for index, repository := range calledRepositories {
|
||||
want := fixtures[index+1].repository
|
||||
if repository != want {
|
||||
t.Fatalf("Resolve order[%d] = %q, want %q", index, repository, want)
|
||||
}
|
||||
}
|
||||
|
||||
if !containsDispatchedSource(dispatched, fixtures[2].source.ID, stored304Revision) {
|
||||
t.Errorf("304 stored revision was not dispatched: %+v", dispatched)
|
||||
}
|
||||
if containsDispatchedSource(dispatched, fixtures[3].source.ID, "") {
|
||||
t.Errorf("auto=false source was dispatched: %+v", dispatched)
|
||||
}
|
||||
if containsDispatchedSource(dispatched, fixtures[4].source.ID, "") {
|
||||
t.Errorf("attention source was dispatched: %+v", dispatched)
|
||||
}
|
||||
|
||||
_, dispatchFailedRuntime := mustLoadPagesSource(t, ctx, fixtures[5].source.ProjectID)
|
||||
if dispatchFailedRuntime.SyncStatus != pagesSourceStatusUpdateAvailable ||
|
||||
dispatchFailedRuntime.LastError != errPagesSourceTaskDispatchFailed ||
|
||||
dispatchFailedRuntime.NextCheckAt == nil || dispatchFailedRuntime.NextCheckAt.Before(retryAt) ||
|
||||
dispatchFailedRuntime.LastSeenRevision == "" {
|
||||
t.Errorf(
|
||||
"dispatch failure runtime = status:%q error:%q next:%v seen:%q, want preserved update and provider deadline >= %v",
|
||||
dispatchFailedRuntime.SyncStatus,
|
||||
dispatchFailedRuntime.LastError,
|
||||
dispatchFailedRuntime.NextCheckAt,
|
||||
dispatchFailedRuntime.LastSeenRevision,
|
||||
retryAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesSourceScannerIncludesOrphanCleanupSummary(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
now := time.Now().Truncate(time.Second)
|
||||
usePagesSourceScannerClock(t, now)
|
||||
|
||||
previousReconcile := reconcilePagesSourceOrphans
|
||||
reconcilePagesSourceOrphans = func(
|
||||
_ context.Context,
|
||||
gotNow time.Time,
|
||||
) (PagesOrphanCleanupSummary, error) {
|
||||
if !gotNow.Equal(now) {
|
||||
t.Errorf("orphan cleanup now = %v, want %v", gotNow, now)
|
||||
}
|
||||
return PagesOrphanCleanupSummary{
|
||||
Candidates: 7,
|
||||
Reconciled: 1,
|
||||
Referenced: 2,
|
||||
LeaseBusy: 1,
|
||||
InvalidMarker: 1,
|
||||
Skipped: 1,
|
||||
Failed: 1,
|
||||
}, nil
|
||||
}
|
||||
t.Cleanup(func() { reconcilePagesSourceOrphans = previousReconcile })
|
||||
|
||||
result, err := (&SourceScanHandler{}).Execute(ctx, []byte("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("SourceScanHandler.Execute() error = %v, want nil", err)
|
||||
}
|
||||
var summary pagesSourceScanSummary
|
||||
if err := json.Unmarshal([]byte(result.Detail), &summary); err != nil {
|
||||
t.Fatalf("json.Unmarshal(scan detail) error = %v, want nil", err)
|
||||
}
|
||||
if summary.OrphanCleanup.Candidates != 7 || summary.OrphanCleanup.Reconciled != 1 ||
|
||||
summary.OrphanCleanup.Referenced != 2 || summary.OrphanCleanup.LeaseBusy != 1 ||
|
||||
summary.OrphanCleanup.InvalidMarker != 1 || summary.OrphanCleanup.Skipped != 1 ||
|
||||
summary.OrphanCleanup.Failed != 1 {
|
||||
t.Errorf("orphan cleanup summary = %+v, want injected result", summary.OrphanCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesSourceScanPayloadAndMeta(t *testing.T) {
|
||||
handler := &SourceScanHandler{}
|
||||
if normalized, err := handler.ValidatePayload(nil); err != nil || string(normalized) != "{}" {
|
||||
t.Errorf("ValidatePayload(nil) = %s, %v; want {}, nil", normalized, err)
|
||||
}
|
||||
if _, err := handler.ValidatePayload([]byte(`{"unexpected":true}`)); err == nil {
|
||||
t.Error("ValidatePayload(unknown field) error = nil, want non-nil")
|
||||
}
|
||||
if PagesSourceScanMeta.InternalOnly || PagesSourceScanMeta.Type != TaskTypePagesSourceScan ||
|
||||
PagesSourceScanMeta.AsynqTask != PagesSourceScanTask || PagesSourceScanMeta.MaxRetry != 0 {
|
||||
t.Errorf("PagesSourceScanMeta = %+v, want public bounded scheduled scanner", PagesSourceScanMeta)
|
||||
}
|
||||
if PagesSourceScanMeta.SupportsTime {
|
||||
t.Error("PagesSourceScanMeta.SupportsTime = true, want empty scanner payload")
|
||||
}
|
||||
}
|
||||
|
||||
func usePagesSourceScannerClock(t *testing.T, now time.Time) {
|
||||
t.Helper()
|
||||
previous := pagesSourceScanNow
|
||||
pagesSourceScanNow = func() time.Time { return now }
|
||||
t.Cleanup(func() { pagesSourceScanNow = previous })
|
||||
}
|
||||
|
||||
func containsDispatchedSource(dispatched []scannerDispatchedSync, sourceID uint, revision string) bool {
|
||||
for _, item := range dispatched {
|
||||
if item.SourceID == sourceID && (revision == "" || item.Revision == revision) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
"Wavelet/openflare/plugins/server/kernel/task"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/util"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
pagesSourceTriggerManualSync = "manual_sync"
|
||||
pagesSourceTriggerScheduledAutoUpdate = "scheduled_auto_update"
|
||||
pagesSourceCreatedBySystem = "system:pages-source-sync"
|
||||
pagesSourceHeartbeatInterval = pagesSourceSyncLeaseDuration / 3
|
||||
pagesSourceCleanupTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errSourceFinalFence = errors.New("pages source final fence rejected")
|
||||
errSourceLeaseHeartbeatLost = errors.New("pages source lease heartbeat lost")
|
||||
sourceCommitNow = time.Now
|
||||
)
|
||||
|
||||
type sourceSyncOutcome struct {
|
||||
Deployment *DeploymentView
|
||||
Reused bool
|
||||
Stale bool
|
||||
}
|
||||
|
||||
type preparedRemoteSource struct {
|
||||
Candidate *SourceCandidate
|
||||
Manifest *deploymentManifest
|
||||
Detail sourceDetail
|
||||
DetailJSON string
|
||||
}
|
||||
|
||||
type sourceIngestState struct {
|
||||
Result ofupload.IngestResult
|
||||
HasIngest bool
|
||||
Referenced bool
|
||||
}
|
||||
|
||||
type sourceCommitState struct {
|
||||
Project *model.PagesProject
|
||||
Source *model.PagesProjectSource
|
||||
Runtime *model.PagesProjectSourceRuntime
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type sourceLeaseHeartbeat struct {
|
||||
cancel context.CancelFunc
|
||||
done <-chan error
|
||||
stopOnce sync.Once
|
||||
stopErr error
|
||||
}
|
||||
|
||||
func startSourceLeaseHeartbeat(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
leaseDuration time.Duration,
|
||||
interval time.Duration,
|
||||
) (context.Context, *sourceLeaseHeartbeat, error) {
|
||||
if snapshot == nil || leaseDuration <= 0 || interval <= 0 || interval >= leaseDuration {
|
||||
return nil, nil, errors.New(errPagesSourceLeaseLost)
|
||||
}
|
||||
renewed, err := renewSourceLease(ctx, snapshot, leaseDuration)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !renewed {
|
||||
return nil, nil, errSourceLeaseHeartbeatLost
|
||||
}
|
||||
|
||||
workCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
heartbeat := &sourceLeaseHeartbeat{cancel: cancel, done: done}
|
||||
util.Go(func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-workCtx.Done():
|
||||
done <- nil
|
||||
return
|
||||
case <-ticker.C:
|
||||
renewed, renewErr := renewSourceLease(workCtx, snapshot, leaseDuration)
|
||||
if renewErr != nil {
|
||||
if workCtx.Err() != nil {
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
done <- renewErr
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if !renewed {
|
||||
done <- errSourceLeaseHeartbeatLost
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return workCtx, heartbeat, nil
|
||||
}
|
||||
|
||||
func (heartbeat *sourceLeaseHeartbeat) stop() error {
|
||||
if heartbeat == nil {
|
||||
return nil
|
||||
}
|
||||
heartbeat.stopOnce.Do(func() {
|
||||
heartbeat.cancel()
|
||||
heartbeat.stopErr = <-heartbeat.done
|
||||
})
|
||||
return heartbeat.stopErr
|
||||
}
|
||||
|
||||
func sourceHeartbeatOutcome(err error) (*sourceSyncOutcome, error) {
|
||||
if errors.Is(err, errSourceLeaseHeartbeatLost) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func recordSourceLeaseFailure(ctx context.Context, snapshot *sourceExecutionSnapshot) {
|
||||
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||
defer cancel()
|
||||
if err := failSourceLease(cleanupCtx, snapshot, errPagesSourceSyncFailed); err != nil {
|
||||
var sourceID uint
|
||||
if snapshot != nil {
|
||||
sourceID = snapshot.SourceID
|
||||
}
|
||||
logger.WarnF(cleanupCtx, "[PagesSource] record failed runtime state failed: source_id=%d error=%v", sourceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), pagesSourceCleanupTimeout)
|
||||
}
|
||||
|
||||
func syncRemoteSourceWithTrigger(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
actor string,
|
||||
triggerType string,
|
||||
) (outcome *sourceSyncOutcome, resultErr error) {
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeRemoteURL {
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" || !validSourceDeploymentTrigger(triggerType) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
recordSourceLeaseFailure(ctx, snapshot)
|
||||
}
|
||||
}()
|
||||
|
||||
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
|
||||
ctx,
|
||||
snapshot,
|
||||
pagesSourceSyncLeaseDuration,
|
||||
pagesSourceHeartbeatInterval,
|
||||
)
|
||||
if err != nil {
|
||||
return sourceHeartbeatOutcome(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = heartbeat.stop()
|
||||
}()
|
||||
|
||||
limits := resolvePagesLimits(workCtx)
|
||||
prepared, err := prepareRemoteSource(workCtx, snapshot, limits)
|
||||
if err != nil {
|
||||
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||
return sourceHeartbeatOutcome(heartbeatErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := prepared.Candidate.Cleanup(); cleanupErr != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup temporary package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
|
||||
}
|
||||
}()
|
||||
|
||||
ingestState, err := resolveSourceIngest(workCtx, snapshot, prepared)
|
||||
if err != nil {
|
||||
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||
return sourceHeartbeatOutcome(heartbeatErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
compensateSourceIngest(ctx, snapshot, ingestState)
|
||||
}()
|
||||
|
||||
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
|
||||
return sourceHeartbeatOutcome(heartbeatErr)
|
||||
}
|
||||
renewed, err := renewSourceLease(ctx, snapshot, pagesSourceSyncLeaseDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !renewed {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "[activate] 正在原子切换生产部署")
|
||||
deployment, reused, referenced, err := commitSourceDeploymentWithTrigger(
|
||||
ctx,
|
||||
snapshot,
|
||||
prepared.Candidate.Checksum,
|
||||
prepared.Candidate.Checksum,
|
||||
prepared.Detail,
|
||||
prepared.DetailJSON,
|
||||
actor,
|
||||
triggerType,
|
||||
prepared.Manifest,
|
||||
ingestState.Result,
|
||||
ingestState.HasIngest,
|
||||
nil,
|
||||
)
|
||||
ingestState.Referenced = referenced
|
||||
if errors.Is(err, errSourceFinalFence) {
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ingestState.Referenced = ingestState.HasIngest && deployment.UploadID == ingestState.Result.Upload.ID
|
||||
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, limits.HistoryCount, 0); pruneErr != nil {
|
||||
logger.ErrorF(ctx,
|
||||
"[PagesSource] strict prune failed: project_id=%d source_id=%d keep=%d error=%v",
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceID,
|
||||
limits.HistoryCount,
|
||||
pruneErr,
|
||||
)
|
||||
}
|
||||
view := buildDeploymentView(deployment)
|
||||
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
|
||||
}
|
||||
|
||||
func prepareRemoteSource(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
limits pagesLimits,
|
||||
) (*preparedRemoteSource, error) {
|
||||
task.AppendLog(ctx, "[download] 正在获取远程部署包")
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: snapshot.RemoteURL,
|
||||
AllowInsecure: snapshot.AllowInsecure,
|
||||
MaxPackageBytes: limits.PackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if candidate == nil || candidate.TempPath == "" || candidate.Checksum == "" || candidate.Format == "" {
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[verify] 正在校验归档结构与入口文件")
|
||||
manifest, err := inspectPagesPackage(candidate.TempPath, candidate.Format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: safeRemoteSourceLabel(candidate.SafeLabel)}
|
||||
detailJSON, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
return &preparedRemoteSource{
|
||||
Candidate: candidate,
|
||||
Manifest: manifest,
|
||||
Detail: detail,
|
||||
DetailJSON: string(detailJSON),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cleanupFailedRemoteCandidate(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
candidate *SourceCandidate,
|
||||
) {
|
||||
if err := candidate.Cleanup(); err != nil {
|
||||
logger.WarnF(ctx, "[PagesSource] cleanup failed preparation package: source_id=%d error=%v", snapshot.SourceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSourceIngest(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
prepared *preparedRemoteSource,
|
||||
) (*sourceIngestState, error) {
|
||||
_, err := findSourceDeployment(
|
||||
ctx,
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceIdentity,
|
||||
prepared.Candidate.Checksum,
|
||||
)
|
||||
if err == nil {
|
||||
return &sourceIngestState{}, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[ingest] 正在保存受管部署包")
|
||||
result, err := ingestPagesDeploymentPackageWithSource(
|
||||
ctx,
|
||||
prepared.Candidate.TempPath,
|
||||
prepared.Candidate.Checksum,
|
||||
snapshot.ProjectID,
|
||||
snapshot.SourceID,
|
||||
sourceDetailLabel(prepared.Detail),
|
||||
prepared.Candidate.Format,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sourceIngestState{Result: result, HasIngest: true}, nil
|
||||
}
|
||||
|
||||
func compensateSourceIngest(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
state *sourceIngestState,
|
||||
) {
|
||||
if state == nil || !state.HasIngest || !state.Result.Created || state.Referenced {
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||
defer cancel()
|
||||
task.AppendLog(cleanupCtx, "[cleanup] 正在补偿未引用的部署包记录")
|
||||
if err := removePagesUploadIfUnreferenced(cleanupCtx, snapshot.ProjectID, state.Result.Upload.ID); err != nil {
|
||||
logger.ErrorF(cleanupCtx,
|
||||
"[PagesSource] compensate upload failed: project_id=%d source_id=%d upload_id=%d error=%v",
|
||||
snapshot.ProjectID, snapshot.SourceID, state.Result.Upload.ID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func findSourceDeployment(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
sourceIdentity string,
|
||||
revision string,
|
||||
) (*model.PagesDeployment, error) {
|
||||
return repository.GetPagesDeploymentBySourceRevision(ctx, projectID, sourceIdentity, revision)
|
||||
}
|
||||
|
||||
func commitSourceDeploymentWithTrigger(
|
||||
ctx context.Context,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
triggerType string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult ofupload.IngestResult,
|
||||
hasIngest bool,
|
||||
nextCheckNotBefore *time.Time,
|
||||
) (*model.PagesDeployment, bool, bool, error) {
|
||||
if snapshot == nil || manifest == nil {
|
||||
return nil, false, false, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if !validSourceDeploymentTrigger(triggerType) {
|
||||
return nil, false, false, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
var committed model.PagesDeployment
|
||||
reused := false
|
||||
ingestReferenced := false
|
||||
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
||||
state, err := lockSourceCommitState(tx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, targetReused, err := resolveSourceDeploymentTx(
|
||||
tx, state, revision, packageChecksum, detail, detailJSON, actor, triggerType,
|
||||
manifest, ingestResult, hasIngest,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := lockSourceDeploymentUploadsTx(tx, target, ingestResult, hasIngest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDeploymentEntry(tx, target.ID, state.Project.RootDir, state.Project.EntryFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON, nextCheckNotBefore); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = *target
|
||||
reused = targetReused
|
||||
ingestReferenced = hasIngest && target.UploadID == ingestResult.Upload.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
return &committed, reused, ingestReferenced, nil
|
||||
}
|
||||
|
||||
func lockSourceCommitState(tx *gorm.DB, snapshot *sourceExecutionSnapshot) (*sourceCommitState, error) {
|
||||
state := &sourceCommitState{}
|
||||
project, err := repository.LockPagesProjectByIDTx(tx, snapshot.ProjectID)
|
||||
if err != nil {
|
||||
return nil, sourceFenceRecordError(err)
|
||||
}
|
||||
if project.ContentConfigVersion != snapshot.ContentConfigVersion {
|
||||
return nil, errSourceFinalFence
|
||||
}
|
||||
source, err := repository.LockPagesProjectSourceByIDAndProjectIDTx(tx, snapshot.SourceID, snapshot.ProjectID)
|
||||
if err != nil {
|
||||
return nil, sourceFenceRecordError(err)
|
||||
}
|
||||
if source.ConfigVersion != snapshot.SourceConfigVersion ||
|
||||
source.SourceIdentity != snapshot.SourceIdentity ||
|
||||
source.SourceType != snapshot.SourceType {
|
||||
return nil, errSourceFinalFence
|
||||
}
|
||||
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, source.ID)
|
||||
if err != nil {
|
||||
return nil, sourceFenceRecordError(err)
|
||||
}
|
||||
state.Project = project
|
||||
state.Source = source
|
||||
state.Runtime = runtime
|
||||
if err := refreshSourceCommitLease(state, snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func refreshSourceCommitLease(state *sourceCommitState, snapshot *sourceExecutionSnapshot) error {
|
||||
if state == nil || state.Runtime == nil || snapshot == nil {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
now := sourceCommitNow()
|
||||
if state.Runtime.LeaseToken != snapshot.LeaseToken || state.Runtime.LeaseExpiresAt == nil ||
|
||||
!state.Runtime.LeaseExpiresAt.After(now) {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
state.Now = now
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceFenceRecordError(err error) error {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
triggerType string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult ofupload.IngestResult,
|
||||
hasIngest bool,
|
||||
) (*model.PagesDeployment, bool, error) {
|
||||
var target model.PagesDeployment
|
||||
err := tx.Where(
|
||||
"project_id = ? AND source_identity = ? AND source_revision = ?",
|
||||
state.Project.ID,
|
||||
state.Source.SourceIdentity,
|
||||
revision,
|
||||
).First(&target).Error
|
||||
if err == nil {
|
||||
return &target, true, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
if !hasIngest {
|
||||
return nil, false, errSourceFinalFence
|
||||
}
|
||||
return createSourceDeploymentTx(
|
||||
tx, state, revision, packageChecksum, detail, detailJSON, actor, triggerType, manifest, ingestResult,
|
||||
)
|
||||
}
|
||||
|
||||
func createSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
revision string,
|
||||
packageChecksum string,
|
||||
detail sourceDetail,
|
||||
detailJSON string,
|
||||
actor string,
|
||||
triggerType string,
|
||||
manifest *deploymentManifest,
|
||||
ingestResult ofupload.IngestResult,
|
||||
) (*model.PagesDeployment, bool, error) {
|
||||
var maxNumber int
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", state.Project.ID).
|
||||
Select("COALESCE(MAX(deployment_number), 0)").
|
||||
Scan(&maxNumber).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
identity := state.Source.SourceIdentity
|
||||
revisionValue := revision
|
||||
target := &model.PagesDeployment{
|
||||
ProjectID: state.Project.ID,
|
||||
DeploymentNumber: maxNumber + 1,
|
||||
Checksum: packageChecksum,
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
UploadID: ingestResult.Upload.ID,
|
||||
FileCount: manifest.FileCount,
|
||||
TotalSize: manifest.TotalSize,
|
||||
CreatedBy: actor,
|
||||
SourceType: state.Source.SourceType,
|
||||
SourceIdentity: &identity,
|
||||
SourceRevision: &revisionValue,
|
||||
SourceLabel: sourceDetailLabel(detail),
|
||||
SourceMeta: detailJSON,
|
||||
TriggerType: triggerType,
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(target)
|
||||
if result.Error != nil {
|
||||
return nil, false, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return reloadSourceDeploymentTx(tx, state.Project.ID, identity, revision)
|
||||
}
|
||||
if err := createSourceDeploymentFilesTx(tx, target.ID, manifest.Files); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return target, false, nil
|
||||
}
|
||||
|
||||
func validSourceDeploymentTrigger(triggerType string) bool {
|
||||
return triggerType == pagesSourceTriggerManualSync || triggerType == pagesSourceTriggerScheduledAutoUpdate
|
||||
}
|
||||
|
||||
func reloadSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
projectID uint,
|
||||
identity string,
|
||||
revision string,
|
||||
) (*model.PagesDeployment, bool, error) {
|
||||
var target model.PagesDeployment
|
||||
err := tx.Where(
|
||||
"project_id = ? AND source_identity = ? AND source_revision = ?",
|
||||
projectID,
|
||||
identity,
|
||||
revision,
|
||||
).First(&target).Error
|
||||
return &target, true, err
|
||||
}
|
||||
|
||||
func createSourceDeploymentFilesTx(tx *gorm.DB, deploymentID uint, files []model.PagesDeploymentFile) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
for index := range files {
|
||||
files[index].DeploymentID = deploymentID
|
||||
}
|
||||
return tx.Create(&files).Error
|
||||
}
|
||||
|
||||
func lockSourceDeploymentUploadsTx(
|
||||
tx *gorm.DB,
|
||||
target *model.PagesDeployment,
|
||||
ingestResult ofupload.IngestResult,
|
||||
hasIngest bool,
|
||||
) error {
|
||||
uploadIDs := []uint64{target.UploadID}
|
||||
if hasIngest && ingestResult.Upload.ID != 0 && ingestResult.Upload.ID != target.UploadID {
|
||||
uploadIDs = append(uploadIDs, ingestResult.Upload.ID)
|
||||
}
|
||||
slices.Sort(uploadIDs)
|
||||
var records []model.Upload
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("id IN ?", uploadIDs).
|
||||
Order("id asc").
|
||||
Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range records {
|
||||
if records[index].ID != target.UploadID {
|
||||
continue
|
||||
}
|
||||
if records[index].Status == model.UploadStatusUsed && records[index].Type == ofupload.ReservedPagesDeploymentType {
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
return errSourceFinalFence
|
||||
}
|
||||
|
||||
func activateSourceDeploymentTx(
|
||||
tx *gorm.DB,
|
||||
state *sourceCommitState,
|
||||
target *model.PagesDeployment,
|
||||
revision string,
|
||||
detailJSON string,
|
||||
nextCheckNotBefore *time.Time,
|
||||
) error {
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", state.Project.ID).
|
||||
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(target).Updates(map[string]any{
|
||||
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
|
||||
"activated_at": &state.Now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(state.Project).Update("active_deployment_id", target.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
finishedAt := sourceCommitNow()
|
||||
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.In(finishedAt.Location())
|
||||
}
|
||||
nextCheckAt = &next
|
||||
}
|
||||
rows, err := repository.UpdatePagesSourceRuntimeByActiveLeaseTx(
|
||||
tx,
|
||||
state.Runtime.SourceID,
|
||||
state.Runtime.LeaseToken,
|
||||
finishedAt,
|
||||
map[string]any{
|
||||
"last_seen_revision": revision,
|
||||
"last_seen_detail": detailJSON,
|
||||
"last_applied_revision": revision,
|
||||
"last_applied_detail": detailJSON,
|
||||
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
|
||||
sourceRuntimeColumnLastError: "",
|
||||
sourceRuntimeColumnLastCheckedAt: &finishedAt,
|
||||
"last_synced_at": &finishedAt,
|
||||
"next_check_at": nextCheckAt,
|
||||
sourceRuntimeColumnLeaseToken: "",
|
||||
sourceRuntimeColumnLeaseExpiresAt: nil,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return errSourceFinalFence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeRemoteSourceLabel(raw string) string {
|
||||
value := strings.ReplaceAll(strings.ToValidUTF8(raw, ""), "\\", "/")
|
||||
value = path.Base(strings.TrimSpace(value))
|
||||
if value == "." || value == "/" {
|
||||
value = ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, character := range value {
|
||||
if character >= 0x20 && character != 0x7f {
|
||||
builder.WriteRune(character)
|
||||
}
|
||||
}
|
||||
value = strings.TrimSpace(builder.String())
|
||||
if value == "" {
|
||||
value = defaultRemoteAssetLabel
|
||||
}
|
||||
if len(value) > remoteSourceMaxSafeLabelBytes {
|
||||
value = value[:remoteSourceMaxSafeLabelBytes]
|
||||
for !utf8.ValidString(value) {
|
||||
_, size := utf8.DecodeLastRuneInString(value)
|
||||
value = value[:len(value)-size]
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sourceSyncResultDetail(outcome *sourceSyncOutcome) string {
|
||||
if outcome == nil || outcome.Deployment == nil {
|
||||
return ""
|
||||
}
|
||||
payload := map[string]any{
|
||||
"deployment_id": outcome.Deployment.ID,
|
||||
"reused": outcome.Reused,
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"deployment_id":%d}`, outcome.Deployment.ID)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/ofupload"
|
||||
"Wavelet/openflare/share/pagesarchive"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupPagesSourceSyncTest(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx := setupPagesSourceTest(t)
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
t.Cleanup(disableStorage)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func mustAcquireRemoteSyncLease(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
source *model.PagesProjectSource,
|
||||
) *sourceExecutionSnapshot {
|
||||
t.Helper()
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil {
|
||||
t.Fatalf("acquireSourceLease(source=%d) error = %v, want nil", source.ID, err)
|
||||
}
|
||||
if got, want := outcome, sourceLeaseAcquired; got != want {
|
||||
t.Fatalf("acquireSourceLease(source=%d) outcome = %q, want %q", source.ID, got, want)
|
||||
}
|
||||
if snapshot == nil {
|
||||
t.Fatalf("acquireSourceLease(source=%d) snapshot = nil, want non-nil", source.ID)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func mustCreateActiveManualDeployment(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
content string,
|
||||
) *model.PagesDeployment {
|
||||
t.Helper()
|
||||
view, err := UploadDeployment(
|
||||
ctx,
|
||||
projectID,
|
||||
testPagesMultipartFile(t, "manual.zip", testPagesZip(t, map[string]string{"index.html": content})),
|
||||
"user:1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDeployment(project=%d) error = %v, want nil", projectID, err)
|
||||
}
|
||||
if _, err := ActivateDeploymentAs(ctx, projectID, view.ID, "user:1"); err != nil {
|
||||
t.Fatalf("ActivateDeploymentAs(project=%d, deployment=%d) error = %v, want nil", projectID, view.ID, err)
|
||||
}
|
||||
deployment, err := repository.GetPagesDeploymentByID(ctx, view.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", view.ID, err)
|
||||
}
|
||||
return deployment
|
||||
}
|
||||
|
||||
func newPagesArchiveServer(t *testing.T, status int, body []byte, beforeWrite func() error) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
if beforeWrite != nil {
|
||||
if err := beforeWrite(); err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/zip")
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="site.zip"`)
|
||||
writer.WriteHeader(status)
|
||||
_, _ = writer.Write(body)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func TestSyncRemoteSourceAtomicallyActivatesAndReusesChecksum(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "sync-success")
|
||||
packageBytes := testPagesZip(t, map[string]string{
|
||||
"index.html": "remote-v1",
|
||||
"assets/app.js": "console.log('ok')",
|
||||
})
|
||||
server := newPagesArchiveServer(t, http.StatusOK, packageBytes, nil)
|
||||
secret := "sync-query-secret"
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
true,
|
||||
)
|
||||
|
||||
firstSnapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
first, err := syncRemoteSource(ctx, firstSnapshot, "user:42")
|
||||
if err != nil {
|
||||
t.Fatalf("syncRemoteSource(first) error = %v, want nil", err)
|
||||
}
|
||||
if first == nil || first.Stale || first.Reused || first.Deployment == nil {
|
||||
t.Fatalf("syncRemoteSource(first) = %+v, want new active deployment", first)
|
||||
}
|
||||
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != first.Deployment.ID {
|
||||
t.Fatalf("project ActiveDeploymentID = %v, want %d", storedProject.ActiveDeploymentID, first.Deployment.ID)
|
||||
}
|
||||
deployment, err := repository.GetPagesDeploymentByID(ctx, first.Deployment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", first.Deployment.ID, err)
|
||||
}
|
||||
expectedHash := sha256.Sum256(packageBytes)
|
||||
if got, want := deployment.Checksum, hex.EncodeToString(expectedHash[:]); got != want {
|
||||
t.Errorf("deployment Checksum = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := deployment.Status, model.PagesDeploymentStatusActive; got != want {
|
||||
t.Errorf("deployment Status = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := deployment.SourceType, PagesSourceTypeRemoteURL; got != want {
|
||||
t.Errorf("deployment SourceType = %q, want %q", got, want)
|
||||
}
|
||||
if deployment.SourceIdentity == nil || *deployment.SourceIdentity != source.SourceIdentity {
|
||||
t.Errorf("deployment SourceIdentity = %v, want %q", deployment.SourceIdentity, source.SourceIdentity)
|
||||
}
|
||||
if deployment.SourceRevision == nil || *deployment.SourceRevision != deployment.Checksum {
|
||||
t.Errorf("deployment SourceRevision = %v, want %q", deployment.SourceRevision, deployment.Checksum)
|
||||
}
|
||||
if got, want := deployment.CreatedBy, "user:42"; got != want {
|
||||
t.Errorf("deployment CreatedBy = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := deployment.TriggerType, pagesSourceTriggerManualSync; got != want {
|
||||
t.Errorf("deployment TriggerType = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(deployment.SourceMeta, secret) || strings.Contains(deployment.SourceLabel, secret) {
|
||||
t.Errorf("deployment provenance = label:%q meta:%q, want no query secret", deployment.SourceLabel, deployment.SourceMeta)
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
|
||||
t.Fatalf("load source runtime error = %v, want nil", err)
|
||||
}
|
||||
if got, want := runtime.SyncStatus, pagesSourceStatusIdle; got != want {
|
||||
t.Errorf("runtime SyncStatus = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := runtime.LastAppliedRevision, deployment.Checksum; got != want {
|
||||
t.Errorf("runtime LastAppliedRevision = %q, want %q", got, want)
|
||||
}
|
||||
if runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
|
||||
t.Errorf("runtime lease = (%q, %v), want cleared", runtime.LeaseToken, runtime.LeaseExpiresAt)
|
||||
}
|
||||
var uploadRecord model.Upload
|
||||
if err := db.DB(ctx).First(&uploadRecord, deployment.UploadID).Error; err != nil {
|
||||
t.Fatalf("load deployment upload %d error = %v, want nil", deployment.UploadID, err)
|
||||
}
|
||||
if got, want := uploadRecord.Status, model.UploadStatusUsed; got != want {
|
||||
t.Errorf("deployment upload Status = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := uploadRecord.Type, ofupload.ReservedPagesDeploymentType; got != want {
|
||||
t.Errorf("deployment upload Type = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprint(uploadRecord.Metadata.Extra[pagesSourceIDMetadataKey]), fmt.Sprint(source.ID); got != want {
|
||||
t.Errorf("deployment upload pages_source_id = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
secondSnapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
second, err := syncRemoteSource(ctx, secondSnapshot, "user:42")
|
||||
if err != nil {
|
||||
t.Fatalf("syncRemoteSource(second) error = %v, want nil", err)
|
||||
}
|
||||
if second == nil || second.Stale || !second.Reused || second.Deployment == nil {
|
||||
t.Fatalf("syncRemoteSource(second) = %+v, want reused active deployment", second)
|
||||
}
|
||||
if got, want := second.Deployment.ID, first.Deployment.ID; got != want {
|
||||
t.Errorf("reused deployment ID = %d, want %d", got, want)
|
||||
}
|
||||
var deploymentCount, uploadCount int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deploymentCount).Error; err != nil {
|
||||
t.Fatalf("count source deployments error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.Upload{}).Count(&uploadCount).Error; err != nil {
|
||||
t.Fatalf("count source uploads error = %v, want nil", err)
|
||||
}
|
||||
if got, want := deploymentCount, int64(1); got != want {
|
||||
t.Errorf("deployment count after identical sync = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := uploadCount, int64(1); got != want {
|
||||
t.Errorf("upload count after identical sync = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRemoteSourceDownloadFailureKeepsOldActive(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "sync-download-fail")
|
||||
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old-active")
|
||||
server := newPagesArchiveServer(t, http.StatusBadGateway, []byte("upstream failed"), nil)
|
||||
secret := "download-failure-secret"
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
true,
|
||||
)
|
||||
|
||||
_, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:2")
|
||||
if err == nil {
|
||||
t.Fatal("syncRemoteSource(download failure) error = nil, want non-nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("syncRemoteSource(download failure) error = %q, want no query secret", err)
|
||||
}
|
||||
assertPagesSyncFailureState(t, ctx, project.ID, source.ID, oldActive.ID, 1)
|
||||
}
|
||||
|
||||
func TestSyncRemoteSourceArchiveFailureKeepsOldActive(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "sync-archive-fail")
|
||||
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old-active")
|
||||
server := newPagesArchiveServer(t, http.StatusOK, []byte("not-a-valid-zip"), nil)
|
||||
secret := "archive-failure-secret"
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
true,
|
||||
)
|
||||
|
||||
_, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:3")
|
||||
if err == nil {
|
||||
t.Fatal("syncRemoteSource(archive failure) error = nil, want non-nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("syncRemoteSource(archive failure) error = %q, want no query secret", err)
|
||||
}
|
||||
assertPagesSyncFailureState(t, ctx, project.ID, source.ID, oldActive.ID, 1)
|
||||
}
|
||||
|
||||
func TestSyncRemoteSourceFinalFenceCompensatesIngest(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "sync-final-fence")
|
||||
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old-active")
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "never-activate"})
|
||||
mutationResult := make(chan error, 1)
|
||||
server := newPagesArchiveServer(t, http.StatusOK, packageBytes, func() error {
|
||||
err := db.DB(context.Background()).Model(&model.PagesProject{}).
|
||||
Where("id = ?", project.ID).
|
||||
Update("content_config_version", gorm.Expr("content_config_version + 1")).Error
|
||||
mutationResult <- err
|
||||
return err
|
||||
})
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token=final-fence-secret",
|
||||
true,
|
||||
)
|
||||
|
||||
outcome, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:4")
|
||||
if err != nil {
|
||||
t.Fatalf("syncRemoteSource(final fence) error = %v, want nil stale outcome", err)
|
||||
}
|
||||
select {
|
||||
case mutationErr := <-mutationResult:
|
||||
if mutationErr != nil {
|
||||
t.Fatalf("content version mutation error = %v, want nil", mutationErr)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("content version mutation was not observed")
|
||||
}
|
||||
if outcome == nil || !outcome.Stale || outcome.Deployment != nil {
|
||||
t.Fatalf("syncRemoteSource(final fence) = %+v, want stale outcome without deployment", outcome)
|
||||
}
|
||||
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != oldActive.ID {
|
||||
t.Errorf("ActiveDeploymentID after final fence = %v, want %d", storedProject.ActiveDeploymentID, oldActive.ID)
|
||||
}
|
||||
var deployments []model.PagesDeployment
|
||||
if err := db.DB(ctx).Where("project_id = ?", project.ID).Find(&deployments).Error; err != nil {
|
||||
t.Fatalf("list deployments after final fence error = %v, want nil", err)
|
||||
}
|
||||
if got, want := len(deployments), 1; got != want {
|
||||
t.Errorf("deployment count after final fence = %d, want %d", got, want)
|
||||
}
|
||||
var uploads []model.Upload
|
||||
if err := db.DB(ctx).Order("id asc").Find(&uploads).Error; err != nil {
|
||||
t.Fatalf("list uploads after final fence error = %v, want nil", err)
|
||||
}
|
||||
var compensated *model.Upload
|
||||
for index := range uploads {
|
||||
if fmt.Sprint(uploads[index].Metadata.Extra[pagesSourceIDMetadataKey]) == fmt.Sprint(source.ID) {
|
||||
compensated = &uploads[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if compensated == nil {
|
||||
t.Fatalf("source upload after final fence = nil, want compensated upload record")
|
||||
}
|
||||
if got, want := compensated.Status, model.UploadStatusDeleted; got != want {
|
||||
t.Errorf("compensated upload Status = %q, want %q", got, want)
|
||||
}
|
||||
var danglingCount int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).
|
||||
Where("upload_id = ?", compensated.ID).
|
||||
Count(&danglingCount).Error; err != nil {
|
||||
t.Fatalf("count compensated upload references error = %v, want nil", err)
|
||||
}
|
||||
if got, want := danglingCount, int64(0); got != want {
|
||||
t.Errorf("deployments referencing compensated upload = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "sync-expiry-recheck")
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "expiry-recheck"})
|
||||
server := newPagesArchiveServer(t, http.StatusOK, packageBytes, nil)
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip",
|
||||
true,
|
||||
)
|
||||
first, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:5")
|
||||
if err != nil || first == nil || first.Deployment == nil {
|
||||
t.Fatalf("syncRemoteSource(seed) = (%+v, %v), want deployment", first, err)
|
||||
}
|
||||
deployment, err := repository.GetPagesDeploymentByID(ctx, first.Deployment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", first.Deployment.ID, err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.PagesProject{}).
|
||||
Where("id = ?", project.ID).
|
||||
Update("active_deployment_id", nil).Error; err != nil {
|
||||
t.Fatalf("clear active deployment error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).
|
||||
Where("id = ?", deployment.ID).
|
||||
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
|
||||
t.Fatalf("reset deployment status error = %v, want nil", err)
|
||||
}
|
||||
|
||||
snapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ? AND lease_token = ?", source.ID, snapshot.LeaseToken).
|
||||
Update("lease_expires_at", &expiresAt).Error; err != nil {
|
||||
t.Fatalf("set deterministic lease expiry error = %v, want nil", err)
|
||||
}
|
||||
originalNow := sourceCommitNow
|
||||
nowCalls := 0
|
||||
sourceCommitNow = func() time.Time {
|
||||
nowCalls++
|
||||
if nowCalls == 1 {
|
||||
return expiresAt.Add(-time.Second)
|
||||
}
|
||||
return expiresAt.Add(time.Second)
|
||||
}
|
||||
t.Cleanup(func() { sourceCommitNow = originalNow })
|
||||
|
||||
_, _, _, err = commitSourceDeployment(
|
||||
ctx,
|
||||
snapshot,
|
||||
deployment.Checksum,
|
||||
deployment.Checksum,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: deployment.SourceLabel},
|
||||
deployment.SourceMeta,
|
||||
"user:5",
|
||||
&deploymentManifest{},
|
||||
ofupload.IngestResult{},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if !errors.Is(err, errSourceFinalFence) {
|
||||
t.Fatalf("commitSourceDeployment(expired after upload lock) error = %v, want %v", err, errSourceFinalFence)
|
||||
}
|
||||
if nowCalls != 2 {
|
||||
t.Fatalf("sourceCommitNow calls = %d, want runtime-lock and post-upload checks", nowCalls)
|
||||
}
|
||||
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if storedProject.ActiveDeploymentID != nil {
|
||||
t.Errorf("ActiveDeploymentID after expiry recheck = %v, want nil", storedProject.ActiveDeploymentID)
|
||||
}
|
||||
storedDeployment, err := repository.GetPagesDeploymentByID(ctx, deployment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesDeploymentByID(%d) after expiry error = %v, want nil", deployment.ID, err)
|
||||
}
|
||||
if got, want := storedDeployment.Status, model.PagesDeploymentStatusUploaded; got != want {
|
||||
t.Errorf("deployment status after expiry recheck = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompensateSourceIngestSurvivesCanceledParentContext(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "sync-canceled-compensation")
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
false,
|
||||
)
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "cancel-compensation"})
|
||||
packagePath := filepath.Join(t.TempDir(), "site.zip")
|
||||
if err := os.WriteFile(packagePath, packageBytes, 0o600); err != nil {
|
||||
t.Fatalf("write test package error = %v, want nil", err)
|
||||
}
|
||||
digest := sha256.Sum256(packageBytes)
|
||||
result, err := ingestPagesDeploymentPackageWithSource(
|
||||
ctx,
|
||||
packagePath,
|
||||
hex.EncodeToString(digest[:]),
|
||||
project.ID,
|
||||
source.ID,
|
||||
"site.zip",
|
||||
pagesarchive.FormatZip,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ingestPagesDeploymentPackageWithSource() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Created {
|
||||
t.Fatal("ingest result Created = false, want a compensatable record")
|
||||
}
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
compensateSourceIngest(canceledCtx, &sourceExecutionSnapshot{
|
||||
ProjectID: project.ID,
|
||||
SourceID: source.ID,
|
||||
}, &sourceIngestState{
|
||||
Result: result,
|
||||
HasIngest: true,
|
||||
})
|
||||
|
||||
var uploadRecord model.Upload
|
||||
if err := db.DB(ctx).Where("id = ?", result.Upload.ID).First(&uploadRecord).Error; err != nil {
|
||||
t.Fatalf("load compensated upload error = %v, want nil", err)
|
||||
}
|
||||
if got, want := uploadRecord.Status, model.UploadStatusDeleted; got != want {
|
||||
t.Errorf("compensated upload status = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPagesSyncFailureState(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
sourceID uint,
|
||||
oldActiveID uint,
|
||||
wantDeploymentCount int64,
|
||||
) {
|
||||
t.Helper()
|
||||
project, err := repository.GetPagesProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", projectID, err)
|
||||
}
|
||||
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID != oldActiveID {
|
||||
t.Errorf("project %d ActiveDeploymentID = %v, want %d", projectID, project.ActiveDeploymentID, oldActiveID)
|
||||
}
|
||||
var deploymentCount int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", projectID).
|
||||
Count(&deploymentCount).Error; err != nil {
|
||||
t.Fatalf("count project %d deployments error = %v, want nil", projectID, err)
|
||||
}
|
||||
if got, want := deploymentCount, wantDeploymentCount; got != want {
|
||||
t.Errorf("project %d deployment count = %d, want %d", projectID, got, want)
|
||||
}
|
||||
var runtime model.PagesProjectSourceRuntime
|
||||
if err := db.DB(ctx).Where("source_id = ?", sourceID).First(&runtime).Error; err != nil {
|
||||
t.Fatalf("load source %d runtime error = %v, want nil", sourceID, err)
|
||||
}
|
||||
if got, want := runtime.SyncStatus, pagesSourceStatusFailed; got != want {
|
||||
t.Errorf("source %d runtime SyncStatus = %q, want %q", sourceID, got, want)
|
||||
}
|
||||
if runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
|
||||
t.Errorf("source %d runtime lease = (%q, %v), want cleared", sourceID, runtime.LeaseToken, runtime.LeaseExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
||||
ctx := setupPagesSourceSyncTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "sync-deleted-upload")
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "content"})
|
||||
server := newPagesArchiveServer(t, http.StatusOK, packageBytes, nil)
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip",
|
||||
true,
|
||||
)
|
||||
snapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
|
||||
// A pre-existing source deployment whose upload was removed must never be
|
||||
// reactivated into a dangling active pointer.
|
||||
identity := source.SourceIdentity
|
||||
revision := strings.Repeat("d", 64)
|
||||
uploadRecord := &model.Upload{
|
||||
ID: 987654321,
|
||||
UserID: 999,
|
||||
FileName: "deleted.zip",
|
||||
FilePath: "deleted.zip",
|
||||
FileSize: 1,
|
||||
MimeType: "application/zip",
|
||||
Extension: "zip",
|
||||
Hash: revision,
|
||||
Type: ofupload.ReservedPagesDeploymentType,
|
||||
Status: model.UploadStatusDeleted,
|
||||
AccessMode: 0,
|
||||
}
|
||||
if err := db.DB(ctx).Create(uploadRecord).Error; err != nil {
|
||||
t.Fatalf("create deleted upload error = %v, want nil", err)
|
||||
}
|
||||
deployment := &model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
DeploymentNumber: 1,
|
||||
Checksum: revision,
|
||||
Status: model.PagesDeploymentStatusUploaded,
|
||||
UploadID: uploadRecord.ID,
|
||||
FileCount: 1,
|
||||
TotalSize: 1,
|
||||
CreatedBy: "user:1",
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
SourceIdentity: &identity,
|
||||
SourceRevision: &revision,
|
||||
SourceLabel: "deleted.zip",
|
||||
SourceMeta: `{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||
TriggerType: pagesSourceTriggerManualSync,
|
||||
}
|
||||
if err := db.DB(ctx).Create(deployment).Error; err != nil {
|
||||
t.Fatalf("create source deployment error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&model.PagesDeploymentFile{
|
||||
DeploymentID: deployment.ID,
|
||||
Path: "index.html",
|
||||
Size: 1,
|
||||
Checksum: revision,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create source deployment file error = %v, want nil", err)
|
||||
}
|
||||
manifest := &deploymentManifest{
|
||||
FileCount: 1,
|
||||
TotalSize: 1,
|
||||
EntryFile: "index.html",
|
||||
}
|
||||
_, _, _, err := commitSourceDeployment(
|
||||
ctx,
|
||||
snapshot,
|
||||
revision,
|
||||
revision,
|
||||
sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: "deleted.zip"},
|
||||
`{"provider":"remote_url","display_name":"deleted.zip"}`,
|
||||
"user:1",
|
||||
manifest,
|
||||
ofupload.IngestResult{},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if !errors.Is(err, errSourceFinalFence) {
|
||||
t.Errorf("commitSourceDeployment(deleted upload) error = %v, want %v", err, errSourceFinalFence)
|
||||
}
|
||||
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if storedProject.ActiveDeploymentID != nil {
|
||||
t.Errorf("ActiveDeploymentID after deleted upload rejection = %v, want nil", storedProject.ActiveDeploymentID)
|
||||
}
|
||||
var activeCount int64
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ? AND status = ?", project.ID, model.PagesDeploymentStatusActive).
|
||||
Count(&activeCount).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("count active deployments error = %v, want nil", err)
|
||||
}
|
||||
if got, want := activeCount, int64(0); got != want {
|
||||
t.Errorf("active deployment count after deleted upload rejection = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
"Wavelet/openflare/plugins/server/kernel/task"
|
||||
"Wavelet/pkg/logger"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PagesSourceActionTask is the private Asynq task type for source actions.
|
||||
PagesSourceActionTask = "openflare:pages_source_action"
|
||||
// TaskTypePagesSourceAction is the internal task meta type.
|
||||
TaskTypePagesSourceAction = "of_pages_source_action"
|
||||
|
||||
sourceActionCheck = "check"
|
||||
sourceActionSync = "sync"
|
||||
)
|
||||
|
||||
var errUnexpectedJSONTrailingValue = errors.New("unexpected trailing JSON value")
|
||||
|
||||
// PagesSourceActionMeta describes check/sync work for Pages deployment sources.
|
||||
var PagesSourceActionMeta = task.TaskMeta{
|
||||
Type: TaskTypePagesSourceAction,
|
||||
AsynqTask: PagesSourceActionTask,
|
||||
Name: "OpenFlare Pages 部署源操作",
|
||||
Description: "检查或同步 Pages 项目部署源",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 2,
|
||||
Queue: task.QueueDefault,
|
||||
Retryable: false,
|
||||
}
|
||||
|
||||
// SourceActionPayload is the credential-free internal queue contract.
|
||||
type SourceActionPayload struct {
|
||||
SourceID uint `json:"source_id"`
|
||||
ConfigVersion int `json:"config_version"`
|
||||
Action string `json:"action"`
|
||||
Actor string `json:"actor"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TargetRevision string `json:"target_revision"`
|
||||
ConfirmedRevision string `json:"confirmed_revision"`
|
||||
}
|
||||
|
||||
// SourceActionHandler executes a validated source action.
|
||||
type SourceActionHandler struct{}
|
||||
|
||||
// ValidatePayload rejects unknown keys and normalizes the internal contract.
|
||||
func (h *SourceActionHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
var input SourceActionPayload
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if err := ensureJSONEOF(decoder); err != nil {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
input.Action = strings.TrimSpace(input.Action)
|
||||
input.Actor = strings.TrimSpace(input.Actor)
|
||||
input.TriggerType = strings.TrimSpace(input.TriggerType)
|
||||
input.TargetRevision = strings.TrimSpace(input.TargetRevision)
|
||||
input.ConfirmedRevision = strings.TrimSpace(input.ConfirmedRevision)
|
||||
if input.Action == sourceActionSync && input.TriggerType == "" {
|
||||
// Keep already queued Phase 2 payloads valid while making every new
|
||||
// dispatch carry an explicit deployment trigger.
|
||||
input.TriggerType = pagesSourceTriggerManualSync
|
||||
}
|
||||
if !validSourceActionPayload(input) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
return json.Marshal(input)
|
||||
}
|
||||
|
||||
func validSourceActionPayload(input SourceActionPayload) bool {
|
||||
if input.SourceID == 0 || input.ConfigVersion <= 0 {
|
||||
return false
|
||||
}
|
||||
if input.Action != sourceActionCheck && input.Action != sourceActionSync {
|
||||
return false
|
||||
}
|
||||
if !validPagesSourceActor(input.Actor) {
|
||||
return false
|
||||
}
|
||||
if !validOptionalSourceRevision(input.TargetRevision) ||
|
||||
!validOptionalSourceRevision(input.ConfirmedRevision) {
|
||||
return false
|
||||
}
|
||||
if input.Action == sourceActionCheck {
|
||||
return input.TriggerType == "" && input.TargetRevision == "" && input.ConfirmedRevision == ""
|
||||
}
|
||||
return validSourceSyncPayload(input)
|
||||
}
|
||||
|
||||
func validSourceSyncPayload(input SourceActionPayload) bool {
|
||||
if !validSourceDeploymentTrigger(input.TriggerType) ||
|
||||
(input.TargetRevision != "" && input.ConfirmedRevision != "") {
|
||||
return false
|
||||
}
|
||||
switch input.TriggerType {
|
||||
case pagesSourceTriggerScheduledAutoUpdate:
|
||||
return input.Actor == pagesSourceCreatedBySystem &&
|
||||
input.TargetRevision != "" && input.ConfirmedRevision == ""
|
||||
case pagesSourceTriggerManualSync:
|
||||
if input.TargetRevision != "" || !strings.HasPrefix(input.Actor, "user:") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates again inside the worker and performs the source action.
|
||||
func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
normalized, err := h.ValidatePayload(payload)
|
||||
if err != nil {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
var input SourceActionPayload
|
||||
if err := json.Unmarshal(normalized, &input); err != nil {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
|
||||
source, err := repository.GetPagesProjectSourceByID(ctx, input.SourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
task.AppendLog(ctx, "[resolve] 来源已不存在,本次任务跳过")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
logger.ErrorF(ctx, "[PagesSource] load source failed: source_id=%d error=%v", input.SourceID, err)
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if source.ConfigVersion != input.ConfigVersion {
|
||||
task.AppendLog(ctx, "[resolve] 来源配置已变化,本次任务跳过")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||
return nil, task.PermanentError(errPagesSourceCheckUnsupported)
|
||||
}
|
||||
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return nil, task.PermanentError(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if (source.SourceType == PagesSourceTypeRemoteURL || input.Action == sourceActionCheck) &&
|
||||
(input.TargetRevision != "" || input.ConfirmedRevision != "") {
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "[resolve] 正在获取来源执行权")
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, input.SourceID, input.ConfigVersion, input.Action)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] acquire lease failed: source_id=%d error=%v", input.SourceID, err)
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
switch outcome {
|
||||
case sourceLeaseBusy:
|
||||
task.AppendLog(ctx, "[resolve] 已有来源任务正在执行,本次任务跳过")
|
||||
return &task.TaskResult{Message: errPagesSourceActionBusy}, nil
|
||||
case sourceLeaseStale:
|
||||
task.AppendLog(ctx, "[resolve] 来源配置或执行权已变化,本次任务跳过")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
case sourceLeaseAcquired:
|
||||
// 已获取执行权,继续执行。
|
||||
}
|
||||
|
||||
if input.Action == sourceActionCheck {
|
||||
return executeGitHubCheckAction(ctx, snapshot)
|
||||
}
|
||||
return executeSourceSyncAction(ctx, source, snapshot, input)
|
||||
}
|
||||
|
||||
func executeGitHubCheckAction(ctx context.Context, snapshot *sourceExecutionSnapshot) (*task.TaskResult, error) {
|
||||
checkResult, checkErr := checkGitHubSource(ctx, snapshot)
|
||||
if checkErr != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] check failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, checkErr)
|
||||
if isPermanentSourceSyncError(checkErr) || shouldSkipGitHubActionRetry(checkErr) {
|
||||
return nil, task.PermanentError(checkErr.Error())
|
||||
}
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if checkResult == nil || checkResult.Stale {
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
return &task.TaskResult{Message: checkResult.Message, Detail: checkResult.Detail}, nil
|
||||
}
|
||||
|
||||
func executeSourceSyncAction(
|
||||
ctx context.Context,
|
||||
source *model.PagesProjectSource,
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
input SourceActionPayload,
|
||||
) (*task.TaskResult, error) {
|
||||
var result *sourceSyncOutcome
|
||||
var err error
|
||||
if source.SourceType == PagesSourceTypeGitHubRelease {
|
||||
result, err = syncGitHubSourceWithTrigger(
|
||||
ctx, snapshot, input.Actor, input.TargetRevision, input.ConfirmedRevision, input.TriggerType,
|
||||
)
|
||||
} else {
|
||||
result, err = syncRemoteSourceWithTrigger(ctx, snapshot, input.Actor, input.TriggerType)
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] sync failed: project_id=%d source_id=%d error=%v", snapshot.ProjectID, snapshot.SourceID, err)
|
||||
if isPermanentSourceSyncError(err) || shouldSkipGitHubActionRetry(err) {
|
||||
return nil, task.PermanentError(errPagesSourceSyncFailed)
|
||||
}
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if result == nil || result.Stale {
|
||||
task.AppendLog(ctx, "[activate] 来源配置或执行权已变化,本次任务未切换部署")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
message := "Pages 部署源同步并发布成功"
|
||||
if result.Reused {
|
||||
message = "Pages 部署源内容未变化,已重新激活现有部署"
|
||||
}
|
||||
return &task.TaskResult{Message: message, Detail: sourceSyncResultDetail(result)}, nil
|
||||
}
|
||||
|
||||
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||
var trailing any
|
||||
err := decoder.Decode(&trailing)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
return errUnexpectedJSONTrailingValue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func validPagesSourceActor(actor string) bool {
|
||||
if actor == pagesSourceCreatedBySystem {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(actor, "user:") {
|
||||
return false
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(actor, "user:"), 10, 64)
|
||||
return err == nil && id > 0
|
||||
}
|
||||
|
||||
func validOptionalSourceRevision(value string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
if len(value) != sourceRevisionHexLength {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && len(decoded) == 32
|
||||
}
|
||||
|
||||
func isPermanentSourceSyncError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := err.Error()
|
||||
return strings.Contains(message, errPagesPackageUnsupported) ||
|
||||
strings.Contains(message, errPagesPackageURLTooLarge) ||
|
||||
strings.Contains(message, errPagesPackageInvalid) ||
|
||||
strings.Contains(message, errPagesPackageEmpty) ||
|
||||
strings.Contains(message, errPagesPackageExtractedTooLarge) ||
|
||||
strings.Contains(message, errPagesPackageFileTooLarge) ||
|
||||
strings.Contains(message, errPagesEntryFileMissing) ||
|
||||
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
|
||||
strings.Contains(message, errPagesSourceReleaseNotFound) ||
|
||||
strings.Contains(message, errPagesSourceDigestInvalid) ||
|
||||
strings.Contains(message, errPagesSourceDigestMismatch) ||
|
||||
strings.Contains(message, errPagesSourceConfirmationNeeded) ||
|
||||
strings.Contains(message, errPagesSourceConfirmationStale)
|
||||
}
|
||||
|
||||
// DispatchSourceAction performs API preflight and enqueues a credential-free action.
|
||||
func DispatchSourceAction(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
action string,
|
||||
actor string,
|
||||
confirmedRevision string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
return dispatchSourceActionByProject(ctx, projectID, action, actor, "", confirmedRevision)
|
||||
}
|
||||
|
||||
func dispatchSourceActionByProject(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
action string,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
action = strings.TrimSpace(action)
|
||||
targetRevision = strings.TrimSpace(targetRevision)
|
||||
confirmedRevision = strings.TrimSpace(confirmedRevision)
|
||||
if action != sourceActionCheck && action != sourceActionSync {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if !validPagesSourceActor(actor) {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
|
||||
source, err := repository.GetPagesProjectSourceByProjectID(ctx, projectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(errPagesSourceNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSourceActionPreflight(ctx, source, action, targetRevision, confirmedRevision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
busy, err := sourceLeaseIsBusy(ctx, source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if busy {
|
||||
return nil, errors.New(errPagesSourceActionBusy)
|
||||
}
|
||||
return dispatchSourceActionSnapshot(ctx, *source, action, actor, targetRevision, confirmedRevision, "manual")
|
||||
}
|
||||
|
||||
func validateSourceActionPreflight(
|
||||
ctx context.Context,
|
||||
source *model.PagesProjectSource,
|
||||
action string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) error {
|
||||
if source == nil {
|
||||
return errors.New(errPagesSourceNotFound)
|
||||
}
|
||||
if source.SourceType != PagesSourceTypeRemoteURL && source.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||
return errors.New(errPagesSourceCheckUnsupported)
|
||||
}
|
||||
if source.SourceType == PagesSourceTypeRemoteURL && (targetRevision != "" || confirmedRevision != "") {
|
||||
return errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if action == sourceActionCheck && (targetRevision != "" || confirmedRevision != "") {
|
||||
return errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
if source.SourceType == PagesSourceTypeGitHubRelease && action == sourceActionSync {
|
||||
if err := preflightGitHubSyncConfirmation(ctx, source.ID, confirmedRevision); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dispatchSourceActionSnapshot(
|
||||
ctx context.Context,
|
||||
source model.PagesProjectSource,
|
||||
action string,
|
||||
actor string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
triggeredBy string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
triggerType := ""
|
||||
if action == sourceActionSync {
|
||||
triggerType = pagesSourceTriggerManualSync
|
||||
}
|
||||
return dispatchSourceActionSnapshotWithTrigger(
|
||||
ctx, source, action, actor, triggerType, targetRevision, confirmedRevision, triggeredBy,
|
||||
)
|
||||
}
|
||||
|
||||
func dispatchSourceActionSnapshotWithTrigger(
|
||||
ctx context.Context,
|
||||
source model.PagesProjectSource,
|
||||
action string,
|
||||
actor string,
|
||||
triggerType string,
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
triggeredBy string,
|
||||
) (*SourceActionReceipt, error) {
|
||||
handler := &SourceActionHandler{}
|
||||
rawPayload, err := json.Marshal(SourceActionPayload{
|
||||
SourceID: source.ID,
|
||||
ConfigVersion: source.ConfigVersion,
|
||||
Action: action,
|
||||
Actor: actor,
|
||||
TriggerType: triggerType,
|
||||
TargetRevision: targetRevision,
|
||||
ConfirmedRevision: confirmedRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New(errPagesSourceActionInvalid)
|
||||
}
|
||||
payload, err := handler.ValidatePayload(rawPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskID, err := task.DispatchTask(ctx, TaskTypePagesSourceAction, payload, triggeredBy)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] dispatch action failed: project_id=%d source_id=%d action=%s error=%v", source.ProjectID, source.ID, action, err)
|
||||
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
||||
}
|
||||
execution, err := repository.GetTaskExecutionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] load dispatched execution failed: source_id=%d task_id=%s error=%v", source.ID, taskID, err)
|
||||
return nil, errors.New(errPagesSourceTaskDispatchFailed)
|
||||
}
|
||||
return &SourceActionReceipt{
|
||||
TaskID: taskID,
|
||||
ExecutionID: strconv.FormatUint(execution.ID, 10),
|
||||
Action: action,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
func TestSourceActionPayloadValidationIsStrictAndCredentialFree(t *testing.T) {
|
||||
handler := &SourceActionHandler{}
|
||||
valid := SourceActionPayload{
|
||||
SourceID: 7,
|
||||
ConfigVersion: 3,
|
||||
Action: sourceActionSync,
|
||||
Actor: "user:42",
|
||||
TriggerType: pagesSourceTriggerManualSync,
|
||||
}
|
||||
raw, err := json.Marshal(valid)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(valid payload) error = %v, want nil", err)
|
||||
}
|
||||
normalized, err := handler.ValidatePayload(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePayload(valid) error = %v, want nil", err)
|
||||
}
|
||||
var got SourceActionPayload
|
||||
if err := json.Unmarshal(normalized, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal(normalized payload) error = %v, want nil", err)
|
||||
}
|
||||
if got != valid {
|
||||
t.Errorf("ValidatePayload(valid) = %+v, want %+v", got, valid)
|
||||
}
|
||||
legacy := valid
|
||||
legacy.TriggerType = ""
|
||||
legacyRaw, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(legacy payload) error = %v, want nil", err)
|
||||
}
|
||||
legacyNormalized, err := handler.ValidatePayload(legacyRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePayload(legacy payload) error = %v, want nil", err)
|
||||
}
|
||||
var legacyGot SourceActionPayload
|
||||
if err := json.Unmarshal(legacyNormalized, &legacyGot); err != nil {
|
||||
t.Fatalf("json.Unmarshal(legacy normalized payload) error = %v, want nil", err)
|
||||
}
|
||||
if legacyGot.TriggerType != pagesSourceTriggerManualSync {
|
||||
t.Errorf("legacy payload trigger_type = %q, want %q", legacyGot.TriggerType, pagesSourceTriggerManualSync)
|
||||
}
|
||||
for _, forbidden := range []string{"remote_url", "content_config_version", "expected_revision", "lease_token", "etag"} {
|
||||
if strings.Contains(string(normalized), forbidden) {
|
||||
t.Errorf("normalized payload = %s, want no forbidden field %q", normalized, forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
invalidPayloads := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{
|
||||
name: "unknown remote URL field",
|
||||
raw: `{"source_id":7,"config_version":3,"action":"sync","actor":"user:42","remote_url":"https://example.com/site.zip?token=secret"}`,
|
||||
},
|
||||
{
|
||||
name: "unknown content version field",
|
||||
raw: `{"source_id":7,"config_version":3,"action":"sync","actor":"user:42","content_config_version":9}`,
|
||||
},
|
||||
{
|
||||
name: "empty actor",
|
||||
raw: `{"source_id":7,"config_version":3,"action":"sync","actor":""}`,
|
||||
},
|
||||
{
|
||||
name: "untrusted system actor",
|
||||
raw: `{"source_id":7,"config_version":3,"action":"sync","actor":"system"}`,
|
||||
},
|
||||
{
|
||||
name: "zero user actor",
|
||||
raw: `{"source_id":7,"config_version":3,"action":"sync","actor":"user:0"}`,
|
||||
},
|
||||
{
|
||||
name: "invalid action",
|
||||
raw: `{"source_id":7,"config_version":3,"action":"activate","actor":"user:42"}`,
|
||||
},
|
||||
{
|
||||
name: "multiple JSON values",
|
||||
raw: `{"source_id":7,"config_version":3,"action":"sync","actor":"user:42"} {}`,
|
||||
},
|
||||
}
|
||||
for _, test := range invalidPayloads {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
normalized, err := handler.ValidatePayload([]byte(test.raw))
|
||||
if err == nil {
|
||||
t.Errorf("ValidatePayload(%s) = %s, nil; want non-nil error", test.raw, normalized)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "secret") {
|
||||
t.Errorf("ValidatePayload(%s) error = %q, want credential-free error", test.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceActionPayloadAcceptsOnlyRealActors(t *testing.T) {
|
||||
tests := []struct {
|
||||
actor string
|
||||
want bool
|
||||
}{
|
||||
{actor: "user:1", want: true},
|
||||
{actor: "user:18446744073709551615", want: true},
|
||||
{actor: pagesSourceCreatedBySystem, want: true},
|
||||
{actor: "", want: false},
|
||||
{actor: "user:0", want: false},
|
||||
{actor: "user:-1", want: false},
|
||||
{actor: "user:not-a-number", want: false},
|
||||
{actor: "system", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := validPagesSourceActor(test.actor); got != test.want {
|
||||
t.Errorf("validPagesSourceActor(%q) = %t, want %t", test.actor, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCheckActionIsPermanentWithoutExposingURL(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "task-remote-check")
|
||||
secret := "task-query-secret"
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token="+secret,
|
||||
false,
|
||||
)
|
||||
raw, err := json.Marshal(SourceActionPayload{
|
||||
SourceID: source.ID,
|
||||
ConfigVersion: source.ConfigVersion,
|
||||
Action: sourceActionCheck,
|
||||
Actor: "user:9",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(check payload) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
result, err := (&SourceActionHandler{}).Execute(ctx, raw)
|
||||
if result != nil {
|
||||
t.Errorf("SourceActionHandler.Execute(remote check) result = %+v, want nil", result)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("SourceActionHandler.Execute(remote check) error = nil, want permanent error")
|
||||
}
|
||||
if !errors.Is(err, asynq.SkipRetry) {
|
||||
t.Errorf("SourceActionHandler.Execute(remote check) error = %v, want errors.Is(asynq.SkipRetry)", err)
|
||||
}
|
||||
if got, want := err.Error(), errPagesSourceCheckUnsupported; got != want {
|
||||
t.Errorf("SourceActionHandler.Execute(remote check) error = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) || strings.Contains(string(raw), secret) {
|
||||
t.Errorf("remote check result error/payload = %q / %s, want no URL secret", err, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesSourceActionMeta(t *testing.T) {
|
||||
if PagesSourceActionMeta.InternalOnly {
|
||||
t.Error("PagesSourceActionMeta.InternalOnly = true, want false")
|
||||
}
|
||||
if PagesSourceActionMeta.Type != TaskTypePagesSourceAction {
|
||||
t.Errorf("PagesSourceActionMeta.Type = %q, want %q", PagesSourceActionMeta.Type, TaskTypePagesSourceAction)
|
||||
}
|
||||
if PagesSourceActionMeta.AsynqTask != PagesSourceActionTask {
|
||||
t.Errorf("PagesSourceActionMeta.AsynqTask = %q, want %q", PagesSourceActionMeta.AsynqTask, PagesSourceActionTask)
|
||||
}
|
||||
if PagesSourceActionMeta.Retryable {
|
||||
t.Error("PagesSourceActionMeta.Retryable = true, want false for manual retry API")
|
||||
}
|
||||
if PagesSourceActionMeta.MaxRetry <= 0 {
|
||||
t.Errorf("PagesSourceActionMeta.MaxRetry = %d, want bounded transient retries", PagesSourceActionMeta.MaxRetry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/repository"
|
||||
|
||||
"Wavelet/openflare/plugins/server/kernel/model"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
)
|
||||
|
||||
func setupPagesSourceTest(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
cleanup := setupPagesTestDB(t)
|
||||
t.Cleanup(cleanup)
|
||||
sqlDB, err := db.DB(t.Context()).DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB().DB() error = %v, want nil", err)
|
||||
}
|
||||
// SQLite :memory: is scoped to one connection. Keeping one connection also
|
||||
// makes lease tests exercise the production CAS without creating empty
|
||||
// per-connection databases.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
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{
|
||||
Name: "Source " + slug,
|
||||
Slug: slug,
|
||||
Enabled: true,
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject(%q) error = %v, want nil", slug, err)
|
||||
}
|
||||
project, err := repository.GetPagesProjectByID(ctx, view.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", view.ID, err)
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
func mustConfigureRemoteSource(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
remoteURL string,
|
||||
allowInsecure bool,
|
||||
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||
t.Helper()
|
||||
_, err := UpdateSource(ctx, projectID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: remoteURL,
|
||||
AllowInsecure: allowInsecure,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSource(%d, %q) error = %v, want nil", projectID, remoteURL, err)
|
||||
}
|
||||
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 TestValidateRemoteSourceInputRejectsModeIncompatibleFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input SourceUpdateInput
|
||||
}{
|
||||
{
|
||||
name: "missing source type",
|
||||
input: SourceUpdateInput{
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote rejects repository field",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RepositoryURL: "https://github.com/example/site",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote rejects automatic updates",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
AutoUpdateEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing remote url",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := validateRemoteSourceInput(test.input); err == nil {
|
||||
t.Errorf("validateRemoteSourceInput(%+v) error = nil, want non-nil", test.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSourceNewRemoteRequiresExplicitURL(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "remote-requires-url")
|
||||
_, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("UpdateSource(new remote without URL) error = nil, want non-nil")
|
||||
}
|
||||
if got, want := err.Error(), errPagesSourceRemoteURLRequired; got != want {
|
||||
t.Errorf("UpdateSource(new remote without URL) error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "remote-crud")
|
||||
firstURL := "https://Artifacts.Example.com:443/dist/site.zip?token=first-secret&expires=1"
|
||||
source, runtime := mustConfigureRemoteSource(t, ctx, project.ID, firstURL, false)
|
||||
|
||||
if got, want := source.ConfigVersion, 1; got != want {
|
||||
t.Errorf("new source ConfigVersion = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := runtime.SyncStatus, pagesSourceStatusIdle; got != want {
|
||||
t.Errorf("new runtime SyncStatus = %q, want %q", got, want)
|
||||
}
|
||||
view, err := GetSource(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := view.RemoteURL, firstURL; got != want {
|
||||
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: firstURL,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, no-op) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
var unchangedSource model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("id = ?", source.ID).First(&unchangedSource).Error; err != nil {
|
||||
t.Fatalf("load no-op source error = %v, want nil", err)
|
||||
}
|
||||
if got, want := unchangedSource.ConfigVersion, source.ConfigVersion; got != want {
|
||||
t.Errorf("no-op source ConfigVersion = %d, want unchanged %d", got, want)
|
||||
}
|
||||
|
||||
seenRevision := strings.Repeat("a", 64)
|
||||
appliedRevision := strings.Repeat("b", 64)
|
||||
future := time.Now().Add(time.Hour)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
Where("source_id = ?", source.ID).
|
||||
Updates(map[string]any{
|
||||
"last_seen_revision": seenRevision,
|
||||
"last_seen_detail": `{"provider":"remote_url","display_name":"new.zip"}`,
|
||||
"last_applied_revision": appliedRevision,
|
||||
"last_applied_detail": `{"provider":"remote_url","display_name":"old.zip"}`,
|
||||
"sync_status": pagesSourceStatusSyncing,
|
||||
"lease_token": "in-flight",
|
||||
"lease_expires_at": &future,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed source runtime error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Keep the same URL while enabling insecure TLS. The identity and cursor must
|
||||
// survive, while the in-flight lease is fenced.
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: firstURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, preserve URL) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
preservedSource, preservedRuntime, err := loadSourceByProject(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadSourceByProject(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := preservedSource.RemoteURL, firstURL; got != want {
|
||||
t.Errorf("preserved RemoteURL = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := preservedSource.ConfigVersion, 2; got != want {
|
||||
t.Errorf("preserved source ConfigVersion = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := preservedSource.SourceIdentity, source.SourceIdentity; got != want {
|
||||
t.Errorf("preserved source identity = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := preservedRuntime.LastSeenRevision, seenRevision; got != want {
|
||||
t.Errorf("preserved LastSeenRevision = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := preservedRuntime.SyncStatus, pagesSourceStatusUpdateAvailable; got != want {
|
||||
t.Errorf("preserved runtime SyncStatus = %q, want %q", got, want)
|
||||
}
|
||||
if preservedRuntime.LeaseToken != "" || preservedRuntime.LeaseExpiresAt != nil {
|
||||
t.Errorf("preserved runtime lease = (%q, %v), want cleared", preservedRuntime.LeaseToken, preservedRuntime.LeaseExpiresAt)
|
||||
}
|
||||
|
||||
// Replacing only the query secret keeps the canonical identity and cursors.
|
||||
queryReplacementURL := "https://artifacts.example.com/dist/site.zip?token=second-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: queryReplacementURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, query replacement) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
querySource, queryRuntime, err := loadSourceByProject(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadSourceByProject(%d) after query replacement error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := querySource.SourceIdentity, source.SourceIdentity; got != want {
|
||||
t.Errorf("query replacement identity = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := queryRuntime.LastSeenRevision, seenRevision; got != want {
|
||||
t.Errorf("query replacement LastSeenRevision = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Replacing the path changes identity and clears all remote cursors.
|
||||
pathReplacementURL := "https://artifacts.example.com/dist/other.zip?token=third-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: pathReplacementURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, path replacement) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
pathSource, pathRuntime, err := loadSourceByProject(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadSourceByProject(%d) after path replacement error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if pathSource.SourceIdentity == source.SourceIdentity {
|
||||
t.Errorf("path replacement identity = %q, want a new identity", pathSource.SourceIdentity)
|
||||
}
|
||||
if pathRuntime.LastSeenRevision != "" || pathRuntime.LastAppliedRevision != "" {
|
||||
t.Errorf("path replacement cursors = (%q, %q), want empty", pathRuntime.LastSeenRevision, pathRuntime.LastAppliedRevision)
|
||||
}
|
||||
if got, want := pathRuntime.SyncStatus, pagesSourceStatusIdle; got != want {
|
||||
t.Errorf("path replacement SyncStatus = %q, want %q", got, want)
|
||||
}
|
||||
pathView, err := GetSource(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(%d) after path replacement error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := pathView.RemoteURL, pathReplacementURL; got != want {
|
||||
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSourceIdentityIgnoresQueryAndNormalizesDefaultPort(t *testing.T) {
|
||||
first, err := parseRemoteSourceURL("HTTPS://Artifacts.Example.com:443/dist/../dist/site.zip?token=one")
|
||||
if err != nil {
|
||||
t.Fatalf("parseRemoteSourceURL(first) error = %v, want nil", err)
|
||||
}
|
||||
second, err := parseRemoteSourceURL("https://artifacts.example.com/dist/site.zip?token=two")
|
||||
if err != nil {
|
||||
t.Fatalf("parseRemoteSourceURL(second) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := remoteSourceIdentity(first), remoteSourceIdentity(second); got != want {
|
||||
t.Errorf("remoteSourceIdentity(first) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSourceIsIdempotentAndKeepsDeploymentState(t *testing.T) {
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "source-delete")
|
||||
source, _ := mustConfigureRemoteSource(
|
||||
t,
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=delete-secret",
|
||||
false,
|
||||
)
|
||||
deployment := &model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
DeploymentNumber: 1,
|
||||
Checksum: strings.Repeat("c", 64),
|
||||
Status: model.PagesDeploymentStatusActive,
|
||||
CreatedBy: "user:1",
|
||||
SourceType: "manual_upload",
|
||||
TriggerType: "manual_upload",
|
||||
}
|
||||
if err := db.DB(ctx).Create(deployment).Error; err != nil {
|
||||
t.Fatalf("create deployment error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.PagesProject{}).
|
||||
Where("id = ?", project.ID).
|
||||
Update("active_deployment_id", deployment.ID).Error; err != nil {
|
||||
t.Fatalf("set active deployment error = %v, want nil", err)
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= 2; attempt++ {
|
||||
view, err := DeleteSource(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteSource(%d), attempt %d error = %v, want nil", project.ID, attempt, err)
|
||||
}
|
||||
if got, want := view.SourceType, PagesSourceTypeManual; got != want {
|
||||
t.Errorf("DeleteSource(%d), attempt %d SourceType = %q, want %q", project.ID, attempt, got, want)
|
||||
}
|
||||
}
|
||||
var sourceCount, runtimeCount, deploymentCount int64
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSource{}).Where("id = ?", source.ID).Count(&sourceCount).Error; err != nil {
|
||||
t.Fatalf("count source error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Count(&runtimeCount).Error; err != nil {
|
||||
t.Fatalf("count runtime error = %v, want nil", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("id = ?", deployment.ID).Count(&deploymentCount).Error; err != nil {
|
||||
t.Fatalf("count deployment error = %v, want nil", err)
|
||||
}
|
||||
if sourceCount != 0 || runtimeCount != 0 || deploymentCount != 1 {
|
||||
t.Errorf("DeleteSource counts = source:%d runtime:%d deployment:%d, want 0, 0, 1", sourceCount, runtimeCount, deploymentCount)
|
||||
}
|
||||
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != deployment.ID {
|
||||
t.Errorf("active deployment = %v, want %d", storedProject.ActiveDeploymentID, deployment.ID)
|
||||
}
|
||||
|
||||
manual, err := GetSource(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(%d) after delete error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := fmt.Sprint(manual.SourceType), PagesSourceTypeManual; got != want {
|
||||
t.Errorf("GetSource(%d).SourceType = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user