mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 14:56:38 +08:00
feat(pages): configurable limits, multi-format packages, and dual versioning
Make Pages package size and history retention system-configurable, support zip/tar.gz/tar.xz/tar.bz2/tar/7z uploads, prune history with clear keep-N semantics, and rebind agent config to the live active Pages deployment so main-config rollback never depends on pruned packages.
This commit is contained in:
@@ -2,8 +2,6 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -11,21 +9,19 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
)
|
||||
|
||||
const (
|
||||
pagesMaxExtractedFileBytes = 100 * 1024 * 1024
|
||||
pagesDirPerm = 0o755
|
||||
pagesFilePerm = 0o644
|
||||
pagesManifestFilePerm = 0o644
|
||||
pagesDirPerm = 0o755
|
||||
pagesFilePerm = 0o644
|
||||
pagesManifestFilePerm = 0o644
|
||||
)
|
||||
|
||||
type pagesSourceDocument struct {
|
||||
@@ -223,89 +219,26 @@ func referencedPagesDeployments(config *protocol.ActiveConfigResponse) ([]pagesD
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func findCommonRootPrefix(files []*zip.File) (string, error) {
|
||||
var firstFilePath string
|
||||
hasMultipleFiles := false
|
||||
for _, item := range files {
|
||||
relativePath, skip, err := normalizePagesArchivePath(item.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
normalizedPath := filepath.ToSlash(relativePath)
|
||||
if firstFilePath == "" {
|
||||
firstFilePath = normalizedPath
|
||||
} else {
|
||||
hasMultipleFiles = true
|
||||
}
|
||||
}
|
||||
if firstFilePath == "" {
|
||||
return "", nil
|
||||
}
|
||||
parts := strings.Split(firstFilePath, "/")
|
||||
if len(parts) <= 1 {
|
||||
return "", nil
|
||||
}
|
||||
commonPrefix := parts[0] + "/"
|
||||
if hasMultipleFiles {
|
||||
for _, item := range files {
|
||||
relativePath, skip, err := normalizePagesArchivePath(item.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
normalizedPath := filepath.ToSlash(relativePath)
|
||||
if !strings.HasPrefix(normalizedPath, commonPrefix) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return commonPrefix, nil
|
||||
}
|
||||
|
||||
func extractPagesPackage(packageBytes []byte, releaseDir string, deployment pagesDeploymentSource) error {
|
||||
tmpDir := releaseDir + ".tmp"
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
if err := os.MkdirAll(tmpDir, pagesDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(packageBytes), int64(len(packageBytes)))
|
||||
format, err := pagesarchive.DetectFormat("", packageBytes)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("open Pages zip: %w", err)
|
||||
return fmt.Errorf("detect Pages package format: %w", err)
|
||||
}
|
||||
commonPrefix, err := findCommonRootPrefix(reader.File)
|
||||
if err != nil {
|
||||
// Control plane already inspected and accepted this package. Agent only
|
||||
// verifies download integrity (checksum) and performs local-safe extract
|
||||
// (path escape / symlink guards). Size and file-count limits are not re-applied.
|
||||
if err := pagesarchive.ExtractBytes(packageBytes, format, tmpDir, pagesarchive.ExtractOptions{
|
||||
StripCommonRoot: true,
|
||||
EnforceLimits: false,
|
||||
}); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return err
|
||||
}
|
||||
for _, item := range reader.File {
|
||||
relativePath, skip, err := normalizePagesArchivePath(item.Name)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
if commonPrefix != "" {
|
||||
slashPath := filepath.ToSlash(relativePath)
|
||||
if strings.HasPrefix(slashPath, commonPrefix) {
|
||||
relativePath = filepath.FromSlash(strings.TrimPrefix(slashPath, commonPrefix))
|
||||
}
|
||||
}
|
||||
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("pages package contains unsupported symlink: %s", relativePath)
|
||||
}
|
||||
if err := extractPagesFile(item, filepath.Join(tmpDir, relativePath)); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("extract Pages package: %w", err)
|
||||
}
|
||||
if err := writePagesMarker(tmpDir, deployment); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
@@ -315,42 +248,6 @@ func extractPagesPackage(packageBytes []byte, releaseDir string, deployment page
|
||||
return os.Rename(tmpDir, releaseDir)
|
||||
}
|
||||
|
||||
func copyPagesZipEntryContent(dst io.Writer, src io.Reader, declaredSize uint64) (int64, error) {
|
||||
if declaredSize > pagesMaxExtractedFileBytes || declaredSize > uint64(math.MaxInt64) {
|
||||
return 0, errors.New("pages file size out of bounds")
|
||||
}
|
||||
if declaredSize > 0 {
|
||||
return io.CopyN(dst, src, int64(declaredSize)) //nolint:gosec // declaredSize is bounded to math.MaxInt64 above
|
||||
}
|
||||
limited := io.LimitReader(src, pagesMaxExtractedFileBytes+1)
|
||||
written, err := io.Copy(dst, limited)
|
||||
if written > pagesMaxExtractedFileBytes {
|
||||
return written, errors.New("pages file size out of bounds")
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
func extractPagesFile(item *zip.File, targetPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), pagesDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := item.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = source.Close() }()
|
||||
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, pagesFilePerm) //nolint:gosec // targetPath is under managed PagesDir from validated zip entry
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = target.Close() }()
|
||||
_, err = copyPagesZipEntryContent(target, source, item.UncompressedSize64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", item.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func switchPagesCurrentDir(baseDir string, deploymentID uint, releaseDir string) error {
|
||||
currentDir := pagesCurrentDir(baseDir, deploymentID)
|
||||
previousDir := currentDir + ".previous"
|
||||
@@ -440,24 +337,6 @@ func copyPagesDir(sourceDir string, targetDir string) error {
|
||||
})
|
||||
}
|
||||
|
||||
func normalizePagesArchivePath(raw string) (string, bool, error) {
|
||||
name := strings.TrimSpace(filepath.ToSlash(raw))
|
||||
if name == "" || strings.HasSuffix(name, "/") {
|
||||
return "", true, nil
|
||||
}
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return "", false, fmt.Errorf("pages package contains absolute path: %s", raw)
|
||||
}
|
||||
cleaned := path.Clean(name)
|
||||
if cleaned == "." {
|
||||
return "", true, nil
|
||||
}
|
||||
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
|
||||
return "", false, fmt.Errorf("pages package path escapes deployment root: %s", raw)
|
||||
}
|
||||
return filepath.FromSlash(cleaned), false, nil
|
||||
}
|
||||
|
||||
func markerMatches(dir string, deployment pagesDeploymentSource) bool {
|
||||
data, err := os.ReadFile(filepath.Join(dir, ".openflare-pages.json")) //nolint:gosec // dir is managed PagesDir
|
||||
if err != nil {
|
||||
|
||||
@@ -525,7 +525,7 @@ func TestSyncOnceRejectsPagesZipSlipBeforeApply(t *testing.T) {
|
||||
service.SetPagesDir(t.TempDir())
|
||||
|
||||
err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-102", Checksum: "pages-config-checksum"})
|
||||
if err == nil || !strings.Contains(err.Error(), "escapes deployment root") {
|
||||
if err == nil || (!strings.Contains(err.Error(), "escapes deployment root") && !strings.Contains(err.Error(), "escapes directory")) {
|
||||
t.Fatalf("expected zip-slip rejection, got %v", err)
|
||||
}
|
||||
if len(manager.applyRouteContents) != 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/pages"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
"gorm.io/gorm"
|
||||
@@ -38,10 +39,20 @@ func getActiveConfigForAgent(ctx context.Context) (*ConfigResponse, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Main config version history is independent of Pages deployment history.
|
||||
// Agents always receive pages routes bound to each project's current active
|
||||
// deployment so config rollback never depends on pruned packages.
|
||||
sourceJSON := version.SnapshotJSON
|
||||
if rebound, rebindErr := pages.RebindSnapshotPagesToCurrentActive(ctx, version.SnapshotJSON); rebindErr != nil {
|
||||
return nil, rebindErr
|
||||
} else if strings.TrimSpace(rebound) != "" {
|
||||
sourceJSON = rebound
|
||||
}
|
||||
|
||||
return &ConfigResponse{
|
||||
Version: version.Version,
|
||||
Checksum: version.Checksum,
|
||||
SourceConfigJSON: version.SnapshotJSON,
|
||||
SourceConfigJSON: sourceJSON,
|
||||
SupportFiles: sourceSupportFiles(supportFiles),
|
||||
CreatedAt: version.CreatedAt,
|
||||
}, nil
|
||||
|
||||
@@ -59,6 +59,9 @@ func validateOptionWithState(ctx context.Context, option model.OpenFlareOption,
|
||||
if err := validateAgentOption(option.Key, option.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePagesOption(option.Key, option.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateUptimeKumaOption(ctx, option.Key, option.Value, state)
|
||||
}
|
||||
|
||||
@@ -109,6 +112,23 @@ func validateAgentOption(key, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePagesOption(key, value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch key {
|
||||
case model.ConfigKeyPagesMaxPackageSizeMB:
|
||||
intValue, err := strconv.Atoi(trimmed)
|
||||
if err != nil || intValue < 1 || intValue > 2048 {
|
||||
return fmt.Errorf("%s 必须为 1~2048 的整数(MiB)", key)
|
||||
}
|
||||
case model.ConfigKeyPagesMaxHistoryCount:
|
||||
intValue, err := strconv.Atoi(trimmed)
|
||||
if err != nil || intValue < 0 {
|
||||
return fmt.Errorf("%s 必须为大于等于 0 的整数(0 表示不限制)", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUptimeKumaOption(ctx context.Context, key, value string, state map[string]string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch key {
|
||||
|
||||
@@ -14,9 +14,13 @@ const (
|
||||
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
||||
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
||||
errPagesPackageMissing = "缺少 Pages 部署包"
|
||||
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件"
|
||||
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件"
|
||||
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
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path"
|
||||
@@ -22,17 +20,22 @@ import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
)
|
||||
|
||||
const (
|
||||
pagesMaxDeploymentFiles = 1000
|
||||
pagesMaxDeploymentBytes = 100 * 1024 * 1024
|
||||
defaultPagesEntryFile = "index.html"
|
||||
defaultPagesFallbackPath = "/index.html"
|
||||
pagesDeploymentUploadType = "openflare_pages_deployment"
|
||||
mimeTypeApplicationZip = "application/zip"
|
||||
pagesMaxPathLength = 512
|
||||
bytesPerKiB = 1024
|
||||
pagesMaxDeploymentFiles = 1000
|
||||
defaultPagesMaxPackageSizeMB = 100
|
||||
maxPagesMaxPackageSizeMB = 2048
|
||||
defaultPagesMaxHistoryCount = 20
|
||||
defaultPagesEntryFile = "index.html"
|
||||
defaultPagesFallbackPath = "/index.html"
|
||||
pagesDeploymentUploadType = "openflare_pages_deployment"
|
||||
pagesMaxPathLength = 512
|
||||
bytesPerMiB = 1024 * 1024
|
||||
pagesExtractedSizeMultiplier = 4
|
||||
pagesMinExtractedSizeBytes = 100 * bytesPerMiB
|
||||
)
|
||||
|
||||
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
|
||||
@@ -42,6 +45,14 @@ type deploymentManifest struct {
|
||||
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 {
|
||||
@@ -51,6 +62,38 @@ func isUniqueConstraintError(err error) bool {
|
||||
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 {
|
||||
if value < 0 {
|
||||
historyCount = 0
|
||||
} else {
|
||||
historyCount = value
|
||||
}
|
||||
}
|
||||
|
||||
packageBytes := int64(packageMB) * bytesPerMiB
|
||||
extractedBytes := packageBytes * pagesExtractedSizeMultiplier
|
||||
if extractedBytes < pagesMinExtractedSizeBytes {
|
||||
extractedBytes = 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
|
||||
@@ -76,7 +119,7 @@ func validateAndNormalizePagesRootDir(raw string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("pages 根目录长度不能超过 512") // error 消息首字母小写
|
||||
return "", errors.New("pages 根目录长度不能超过 512")
|
||||
}
|
||||
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
|
||||
return "", errors.New("pages 根目录包含不支持的字符")
|
||||
@@ -151,29 +194,50 @@ func normalizePagesEntryFile(raw string) string {
|
||||
return strings.TrimPrefix(value, "/")
|
||||
}
|
||||
|
||||
func persistPagesUploadTemp(fileHeader *multipart.FileHeader) (string, string, int64, error) {
|
||||
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
|
||||
return "", "", 0, "", err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
temp, err := os.CreateTemp("", "openflare-pages-*.zip")
|
||||
temp, err := os.CreateTemp("", "openflare-pages-*."+safeTempSuffix(format))
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
return "", "", 0, "", err
|
||||
}
|
||||
defer func() { _ = temp.Close() }()
|
||||
hash := sha256.New()
|
||||
limited := io.LimitReader(file, pagesMaxDeploymentBytes+1)
|
||||
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
|
||||
return "", "", 0, "", err
|
||||
}
|
||||
if written > pagesMaxDeploymentBytes {
|
||||
if written > maxPackageBytes {
|
||||
_ = os.Remove(temp.Name())
|
||||
return "", "", 0, fmt.Errorf("pages 部署包不能超过 %d MiB", pagesMaxDeploymentBytes/bytesPerKiB/bytesPerKiB)
|
||||
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"
|
||||
default:
|
||||
return "zip"
|
||||
}
|
||||
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, nil
|
||||
}
|
||||
|
||||
func pagesLegacyRelativeCandidates(project *model.PagesProject, deployment *model.PagesDeployment) []string {
|
||||
@@ -185,6 +249,7 @@ func pagesLegacyRelativeCandidates(project *model.PagesProject, deployment *mode
|
||||
if slug == "" || checksum == "" {
|
||||
return nil
|
||||
}
|
||||
// Legacy artifacts were always stored as .zip.
|
||||
fileName := checksum + ".zip"
|
||||
return []string{
|
||||
filepath.Join("artifacts", slug, fileName),
|
||||
@@ -199,14 +264,16 @@ func ingestPagesDeploymentPackage(
|
||||
checksum string,
|
||||
projectSlug string,
|
||||
fileName string,
|
||||
format pagesarchive.Format,
|
||||
) (upload.IngestResult, error) {
|
||||
systemUser := repository.GetSystemUser(ctx)
|
||||
accessMode := 0
|
||||
extension := pagesarchive.NormalizeNameExtension(fileName, format)
|
||||
return upload.IngestFromLocalPath(ctx, localPath, upload.IngestRequest{
|
||||
UserID: systemUser.ID,
|
||||
FileName: fileName,
|
||||
MimeType: mimeTypeApplicationZip,
|
||||
Extension: "zip",
|
||||
MimeType: pagesarchive.MIMEType(format),
|
||||
Extension: extension,
|
||||
Hash: checksum,
|
||||
Type: pagesDeploymentUploadType,
|
||||
AccessMode: &accessMode,
|
||||
@@ -215,6 +282,7 @@ func ingestPagesDeploymentPackage(
|
||||
Metadata: model.UploadMetadata{
|
||||
Extra: map[string]any{
|
||||
"project_slug": projectSlug,
|
||||
"format": string(format),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -224,167 +292,96 @@ func removeDeploymentArtifact(ctx context.Context, deployment *model.PagesDeploy
|
||||
if deployment == nil {
|
||||
return
|
||||
}
|
||||
if deployment.UploadID > 0 {
|
||||
_, _ = upload.Remove(ctx, deployment.UploadID)
|
||||
if deployment.UploadID == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := upload.Remove(ctx, deployment.UploadID); err != nil {
|
||||
// Soft-delete / storage cleanup failure must not undo DB prune; log for ops.
|
||||
logger.WarnF(ctx,
|
||||
"[Pages] remove deployment artifact failed: deployment_id=%d upload_id=%d error=%v",
|
||||
deployment.ID, deployment.UploadID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func findCommonRootPrefix(files []*zip.File) (string, error) {
|
||||
var firstFilePath string
|
||||
hasMultipleFiles := false
|
||||
for _, item := range files {
|
||||
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
if firstFilePath == "" {
|
||||
firstFilePath = normalizedPath
|
||||
} else {
|
||||
hasMultipleFiles = true
|
||||
}
|
||||
}
|
||||
if firstFilePath == "" {
|
||||
return "", nil
|
||||
}
|
||||
parts := strings.Split(firstFilePath, "/")
|
||||
if len(parts) <= 1 {
|
||||
return "", nil
|
||||
}
|
||||
commonPrefix := parts[0] + "/"
|
||||
if hasMultipleFiles {
|
||||
for _, item := range files {
|
||||
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(normalizedPath, commonPrefix) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return commonPrefix, nil
|
||||
}
|
||||
|
||||
func inspectPagesZip(zipPath string, rootDir string, entryFile string) (*deploymentManifest, error) {
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, errors.New(errPagesPackageInvalidZip)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
commonPrefix, err := findCommonRootPrefix(reader.File)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manifest := &deploymentManifest{
|
||||
Files: []model.PagesDeploymentFile{},
|
||||
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,
|
||||
Limits: pagesarchive.Limits{
|
||||
MaxFiles: limits.MaxFiles,
|
||||
MaxFileBytes: limits.ExtractedBytes,
|
||||
MaxTotalBytes: limits.ExtractedBytes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapPagesArchiveError(err)
|
||||
}
|
||||
targetEntryPath := entryFile
|
||||
if rootDir != "" {
|
||||
targetEntryPath = path.Join(rootDir, entryFile)
|
||||
manifest := &deploymentManifest{
|
||||
Files: make([]model.PagesDeploymentFile, 0, len(archiveManifest.Files)),
|
||||
FileCount: archiveManifest.FileCount,
|
||||
TotalSize: archiveManifest.TotalSize,
|
||||
EntryFile: entryFile,
|
||||
Format: format,
|
||||
}
|
||||
entrySeen := false
|
||||
for _, item := range reader.File {
|
||||
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
if commonPrefix != "" {
|
||||
normalizedPath = strings.TrimPrefix(normalizedPath, commonPrefix)
|
||||
}
|
||||
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("pages 部署包不支持符号链接: %s", normalizedPath)
|
||||
}
|
||||
if item.UncompressedSize64 > pagesMaxDeploymentBytes {
|
||||
return nil, fmt.Errorf("pages 文件过大: %s", normalizedPath)
|
||||
}
|
||||
manifest.FileCount++
|
||||
if manifest.FileCount > pagesMaxDeploymentFiles {
|
||||
return nil, fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
|
||||
}
|
||||
checksum, fileSize, err := checksumZipFile(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", normalizedPath, err)
|
||||
}
|
||||
manifest.TotalSize += fileSize
|
||||
if manifest.TotalSize > pagesMaxDeploymentBytes {
|
||||
return nil, fmt.Errorf("pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/bytesPerKiB/bytesPerKiB)
|
||||
}
|
||||
if normalizedPath == targetEntryPath {
|
||||
entrySeen = true
|
||||
}
|
||||
for _, file := range archiveManifest.Files {
|
||||
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
|
||||
Path: normalizedPath,
|
||||
Size: fileSize,
|
||||
Checksum: checksum,
|
||||
Path: file.Path,
|
||||
Size: file.Size,
|
||||
Checksum: file.Checksum,
|
||||
})
|
||||
}
|
||||
if manifest.FileCount == 0 {
|
||||
return nil, errors.New(errPagesPackageEmpty)
|
||||
}
|
||||
if !entrySeen {
|
||||
return nil, fmt.Errorf("pages 部署包缺少入口文件 %s", targetEntryPath)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func normalizePagesZipPath(raw string) (string, bool, error) {
|
||||
name := strings.TrimSpace(filepath.ToSlash(raw))
|
||||
if name == "" {
|
||||
return "", true, nil
|
||||
func mapPagesArchiveError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(name, "/") {
|
||||
return "", true, 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
|
||||
}
|
||||
if strings.HasPrefix(name, "/") || path.IsAbs(name) {
|
||||
return "", false, fmt.Errorf("pages 部署包不能包含绝对路径: %s", raw)
|
||||
}
|
||||
cleaned := path.Clean(name)
|
||||
if cleaned == "." {
|
||||
return "", true, nil
|
||||
}
|
||||
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
|
||||
return "", false, fmt.Errorf("pages 部署包路径不能逃逸目录: %s", raw)
|
||||
}
|
||||
return cleaned, false, nil
|
||||
}
|
||||
|
||||
func copyPagesZipEntryContent(dst io.Writer, src io.Reader, declaredSize uint64) (int64, error) {
|
||||
if declaredSize > pagesMaxDeploymentBytes || declaredSize > uint64(math.MaxInt64) {
|
||||
return 0, errors.New("pages file size out of bounds")
|
||||
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))
|
||||
}
|
||||
if declaredSize > 0 {
|
||||
return io.CopyN(dst, src, int64(declaredSize)) //nolint:gosec // declaredSize is bounded to math.MaxInt64 above
|
||||
// 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)
|
||||
}
|
||||
limited := io.LimitReader(src, pagesMaxDeploymentBytes+1)
|
||||
written, err := io.Copy(dst, limited)
|
||||
if written > pagesMaxDeploymentBytes {
|
||||
return written, errors.New("pages file size out of bounds")
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
func checksumZipFile(item *zip.File) (string, int64, error) {
|
||||
file, err := item.Open()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
hash := sha256.New()
|
||||
written, err := copyPagesZipEntryContent(hash, file, item.UncompressedSize64)
|
||||
if err != nil {
|
||||
return "", written, err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), written, nil
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@ import (
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -251,20 +252,18 @@ func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart
|
||||
if fileHeader == nil {
|
||||
return nil, errors.New(errPagesPackageMissing)
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
|
||||
return nil, errors.New(errPagesPackageNotZip)
|
||||
}
|
||||
limits := resolvePagesLimits(ctx)
|
||||
rootDir, err := validateAndNormalizePagesRootDir(project.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryFile := normalizePagesEntryFile(project.EntryFile)
|
||||
tempPath, checksum, _, err := persistPagesUploadTemp(fileHeader)
|
||||
tempPath, checksum, _, format, err := persistPagesUploadTemp(fileHeader, limits.PackageBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = os.Remove(tempPath) }()
|
||||
manifest, err := inspectPagesZip(tempPath, rootDir, entryFile)
|
||||
manifest, err := inspectPagesPackage(tempPath, format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -274,6 +273,7 @@ func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart
|
||||
checksum,
|
||||
project.Slug,
|
||||
fileHeader.Filename,
|
||||
format,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -320,10 +320,160 @@ func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart
|
||||
return nil, err
|
||||
}
|
||||
ingestCommitted = true
|
||||
|
||||
// History prune must not fail the already-committed upload. Log and continue;
|
||||
// a later upload (or a retry pass inside prune) will re-attempt cleanup.
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, project.ID, limits.HistoryCount); pruneErr != nil {
|
||||
logger.ErrorF(ctx,
|
||||
"[Pages] prune deployment history failed: project_id=%d keep=%d error=%v",
|
||||
project.ID, limits.HistoryCount, pruneErr,
|
||||
)
|
||||
}
|
||||
|
||||
view := buildDeploymentView(deployment)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
// pruneProjectDeploymentHistory enforces the retention policy for one project.
|
||||
//
|
||||
// Policy (keepCount > 0):
|
||||
// - At most keepCount deployment rows remain for the project.
|
||||
// - The current active deployment is always retained (if any).
|
||||
// - Remaining slots are filled by newest deployments first (id desc).
|
||||
// - All other non-kept deployments are deleted with their file lists and artifacts.
|
||||
//
|
||||
// keepCount <= 0 means unlimited history.
|
||||
//
|
||||
// Concurrency: DB row deletes run in a single transaction after a consistent read
|
||||
// of project + deployments. Concurrent uploads may briefly exceed keepCount; the
|
||||
// next successful prune brings the project back within the limit (eventual).
|
||||
func pruneProjectDeploymentHistory(ctx context.Context, projectID uint, keepCount int) error {
|
||||
if keepCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Two passes: first pass after upload, second pass heals a concurrent race
|
||||
// that inserted another deployment between our list and delete.
|
||||
var lastErr error
|
||||
for pass := 0; pass < 2; pass++ {
|
||||
deleted, err := pruneProjectDeploymentHistoryOnce(ctx, projectID, keepCount)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
break
|
||||
}
|
||||
if deleted == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// pruneProjectDeploymentHistoryOnce performs one list → select → delete cycle.
|
||||
// Returns the number of deployments deleted from the database.
|
||||
func pruneProjectDeploymentHistoryOnce(ctx context.Context, projectID uint, keepCount int) (int, error) {
|
||||
project, err := model.GetPagesProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load pages project: %w", err)
|
||||
}
|
||||
deployments, err := model.ListPagesDeployments(ctx, projectID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list pages deployments: %w", err)
|
||||
}
|
||||
if len(deployments) <= keepCount {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var activeID uint
|
||||
if project.ActiveDeploymentID != nil {
|
||||
activeID = *project.ActiveDeploymentID
|
||||
}
|
||||
toDelete := selectDeploymentsToPrune(deployments, activeID, keepCount)
|
||||
if len(toDelete) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Delete metadata in one transaction so partial prune does not leave
|
||||
// orphan file-list rows without a parent deployment.
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for index := range toDelete {
|
||||
deployment := toDelete[index]
|
||||
// Never delete the active deployment even if project pointer raced.
|
||||
if activeID != 0 && deployment.ID == activeID {
|
||||
continue
|
||||
}
|
||||
if project.ActiveDeploymentID != nil && deployment.ID == *project.ActiveDeploymentID {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("deployment_id = ?", deployment.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
|
||||
return fmt.Errorf("delete deployment files id=%d: %w", deployment.ID, err)
|
||||
}
|
||||
if err := tx.Where("id = ? AND project_id = ?", deployment.ID, projectID).
|
||||
Delete(&model.PagesDeployment{}).Error; err != nil {
|
||||
return fmt.Errorf("delete deployment id=%d: %w", deployment.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Artifacts are best-effort outside the transaction (object storage I/O).
|
||||
for index := range toDelete {
|
||||
deployment := toDelete[index]
|
||||
if activeID != 0 && deployment.ID == activeID {
|
||||
continue
|
||||
}
|
||||
removeDeploymentArtifact(ctx, &deployment)
|
||||
}
|
||||
|
||||
logger.InfoF(ctx,
|
||||
"[Pages] pruned deployment history: project_id=%d keep=%d deleted=%d",
|
||||
projectID, keepCount, len(toDelete),
|
||||
)
|
||||
return len(toDelete), nil
|
||||
}
|
||||
|
||||
// selectDeploymentsToPrune returns deployments that should be removed under the
|
||||
// "at most keepCount, always keep active, fill with newest" policy.
|
||||
// deployments must be ordered newest-first (id desc).
|
||||
func selectDeploymentsToPrune(deployments []model.PagesDeployment, activeID uint, keepCount int) []model.PagesDeployment {
|
||||
if keepCount <= 0 || len(deployments) <= keepCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
keepIDs := make(map[uint]struct{}, keepCount)
|
||||
// 1) Active is always retained and occupies one slot when present.
|
||||
if activeID != 0 {
|
||||
// Only count active if it still exists in the list.
|
||||
for _, deployment := range deployments {
|
||||
if deployment.ID == activeID {
|
||||
keepIDs[activeID] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) Fill remaining slots from newest to oldest.
|
||||
for _, deployment := range deployments {
|
||||
if len(keepIDs) >= keepCount {
|
||||
break
|
||||
}
|
||||
keepIDs[deployment.ID] = struct{}{}
|
||||
}
|
||||
|
||||
toDelete := make([]model.PagesDeployment, 0, len(deployments)-len(keepIDs))
|
||||
for _, deployment := range deployments {
|
||||
if _, keep := keepIDs[deployment.ID]; keep {
|
||||
continue
|
||||
}
|
||||
// Safety: never mark active for deletion.
|
||||
if activeID != 0 && deployment.ID == activeID {
|
||||
continue
|
||||
}
|
||||
toDelete = append(toDelete, deployment)
|
||||
}
|
||||
return toDelete
|
||||
}
|
||||
|
||||
// ActivateDeployment 激活 Pages 部署。
|
||||
func ActivateDeployment(ctx context.Context, projectID uint, deploymentID uint) (*View, error) {
|
||||
project, err := model.GetPagesProjectByID(ctx, projectID)
|
||||
@@ -422,10 +572,14 @@ func openDeploymentPackageFromUpload(ctx context.Context, uploadID uint64, deplo
|
||||
}
|
||||
contentType := opened.ContentType
|
||||
if contentType == "" {
|
||||
contentType = mimeTypeApplicationZip
|
||||
contentType = opened.Upload.MimeType
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
fileName := packageDownloadName(deploymentID, opened.Upload.FileName, contentType)
|
||||
return DeploymentPackage{
|
||||
FileName: fmt.Sprintf("pages-deployment-%d.zip", deploymentID),
|
||||
FileName: fileName,
|
||||
ContentType: contentType,
|
||||
ContentLength: opened.ContentLength,
|
||||
Body: opened.Body,
|
||||
@@ -478,6 +632,7 @@ func hydrateLegacyDeploymentUpload(
|
||||
deployment.Checksum,
|
||||
project.Slug,
|
||||
fmt.Sprintf("pages-deployment-%d.zip", deployment.ID),
|
||||
pagesarchive.FormatZip,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -493,7 +648,26 @@ func hydrateLegacyDeploymentUpload(
|
||||
return &ingestResult.Upload, nil
|
||||
}
|
||||
|
||||
// ensureDeploymentInActiveSnapshot allows Agent package download when the
|
||||
// deployment is the project's current active deployment and that project is
|
||||
// used by at least one pages route in the active main config.
|
||||
//
|
||||
// Main config versions pin historical pages_deployment ids for audit only.
|
||||
// Runtime download always follows the live active Pages deployment (dual
|
||||
// version control); rolling back main config must not require old packages.
|
||||
func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) error {
|
||||
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := model.GetPagesProjectByID(ctx, deployment.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID != deployment.ID {
|
||||
return errors.New(errPagesPackageNotInActiveConfig)
|
||||
}
|
||||
|
||||
version, err := model.GetActiveConfigVersion(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -506,10 +680,30 @@ func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) er
|
||||
return err
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.UpstreamType != "pages" || route.PagesDeployment == nil {
|
||||
if !strings.EqualFold(strings.TrimSpace(route.UpstreamType), "pages") {
|
||||
continue
|
||||
}
|
||||
if route.PagesDeployment.DeploymentID == deploymentID {
|
||||
if route.PagesProjectID != nil && *route.PagesProjectID == project.ID {
|
||||
return nil
|
||||
}
|
||||
if route.PagesDeployment == nil {
|
||||
continue
|
||||
}
|
||||
if route.PagesDeployment.ProjectID == project.ID {
|
||||
return nil
|
||||
}
|
||||
// Frozen snapshot may only carry deployment_id; resolve project via that row.
|
||||
if route.PagesDeployment.DeploymentID == 0 {
|
||||
continue
|
||||
}
|
||||
if route.PagesDeployment.DeploymentID == deployment.ID {
|
||||
return nil
|
||||
}
|
||||
snapDeployment, snapErr := model.GetPagesDeploymentByID(ctx, route.PagesDeployment.DeploymentID)
|
||||
if snapErr != nil {
|
||||
continue
|
||||
}
|
||||
if snapDeployment.ProjectID == project.ID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -518,10 +712,12 @@ func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) er
|
||||
|
||||
type snapshotPagesDeployment struct {
|
||||
DeploymentID uint `json:"deployment_id"`
|
||||
ProjectID uint `json:"project_id"`
|
||||
}
|
||||
|
||||
type snapshotRouteRef struct {
|
||||
UpstreamType string `json:"upstream_type"`
|
||||
PagesProjectID *uint `json:"pages_project_id"`
|
||||
PagesDeployment *snapshotPagesDeployment `json:"pages_deployment"`
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/internal/storage"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -40,6 +43,7 @@ func setupPagesTestDB(t *testing.T) func() {
|
||||
&model.PagesDeployment{},
|
||||
&model.PagesDeploymentFile{},
|
||||
&model.ConfigVersion{},
|
||||
&model.SystemConfig{},
|
||||
))
|
||||
require.NoError(t, sqliteDB.Create(&model.User{
|
||||
ID: 999,
|
||||
@@ -48,8 +52,25 @@ func setupPagesTestDB(t *testing.T) func() {
|
||||
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)
|
||||
// Clear process-global system config RAM cache so tests do not see stale values.
|
||||
_ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxPackageSizeMB)
|
||||
_ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxHistoryCount)
|
||||
return func() {
|
||||
db.SetDB(nil)
|
||||
}
|
||||
@@ -339,6 +360,149 @@ func testPagesZip(t *testing.T, files map[string]string) []byte {
|
||||
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, 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, 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, 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, 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, 2))
|
||||
// unlimited
|
||||
assert.Nil(t, selectDeploymentsToPrune(deployments, 1, 0))
|
||||
}
|
||||
|
||||
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 := model.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 = model.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 := model.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 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()
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/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 := buildLivePagesDeployment(project, activeDeployment)
|
||||
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, fmt.Errorf("pages 配置无效: 缺少 pages_project_id")
|
||||
}
|
||||
|
||||
func loadActivePagesProject(ctx context.Context, projectID uint, siteName string) (*model.PagesProject, *model.PagesDeployment, error) {
|
||||
if siteName == "" {
|
||||
siteName = "pages"
|
||||
}
|
||||
project, err := model.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 := model.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 {
|
||||
entryFile := strings.TrimSpace(project.EntryFile)
|
||||
if entryFile == "" {
|
||||
entryFile = defaultPagesEntryFile
|
||||
}
|
||||
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
|
||||
if fallbackPath == "" {
|
||||
fallbackPath = defaultPagesFallbackPath
|
||||
}
|
||||
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: fmt.Sprintf(
|
||||
"%s/deployments/%d/current",
|
||||
openrestyrender.PagesDirPlaceholder,
|
||||
active.ID,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func rawJSONString(raw json.RawMessage) (string, bool) {
|
||||
if !isPresentJSON(raw) {
|
||||
return "", false
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
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 && (err == gorm.ErrRecordNotFound || strings.Contains(strings.ToLower(err.Error()), "record not found"))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"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,
|
||||
})
|
||||
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__/deployments/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"])
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('pages_max_package_size_mb', '100', 'business', 0, 'Pages 部署包上传大小上限(MiB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('pages_max_history_count', '20', 'business', 0, 'Pages 每个项目最大历史部署保留数(0 表示不限制)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN ('pages_max_package_size_mb', 'pages_max_history_count');
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES
|
||||
('pages_max_package_size_mb', '100', 'business', 0, 'Pages 部署包上传大小上限(MiB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('pages_max_history_count', '20', 'business', 0, 'Pages 每个项目最大历史部署保留数(0 表示不限制)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key IN ('pages_max_package_size_mb', 'pages_max_history_count');
|
||||
@@ -50,6 +50,10 @@ const (
|
||||
ConfigKeyDatabaseAutoCleanupEnabled = "database_auto_cleanup_enabled" // 数据库自动清理开关
|
||||
ConfigKeyDatabaseAutoCleanupRetentionDays = "database_auto_cleanup_retention_days" // 数据库保留天数
|
||||
|
||||
// Pages 静态托管配置
|
||||
ConfigKeyPagesMaxPackageSizeMB = "pages_max_package_size_mb" // Pages 部署包上传大小上限(MiB)
|
||||
ConfigKeyPagesMaxHistoryCount = "pages_max_history_count" // Pages 每个项目最大历史部署保留数(0 表示不限制)
|
||||
|
||||
// UptimeKuma 集成配置
|
||||
ConfigKeyUptimeKumaEnabled = "uptime_kuma_enabled" // UptimeKuma 集成开关
|
||||
ConfigKeyUptimeKumaURL = "uptime_kuma_url" // UptimeKuma URL
|
||||
|
||||
@@ -295,6 +295,18 @@ func getSeedConfigsPart2() []model.SystemConfig {
|
||||
Type: configTypeBusiness,
|
||||
Description: "FRPS 内置 Web 界面端口",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeyPagesMaxPackageSizeMB,
|
||||
Value: "100",
|
||||
Type: configTypeBusiness,
|
||||
Description: "Pages 部署包上传大小上限(MiB)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeyPagesMaxHistoryCount,
|
||||
Value: "20",
|
||||
Type: configTypeBusiness,
|
||||
Description: "Pages 每个项目最大历史部署保留数(0 表示不限制)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user