mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 07:26:36 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,991 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package sync applies control-plane configuration to the local agent runtime.
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
"Wavelet/openflare/share/pagesarchive"
|
||||
)
|
||||
|
||||
const (
|
||||
pagesDirPerm = 0o755
|
||||
pagesFilePerm = 0o644
|
||||
pagesManifestFilePerm = 0o644
|
||||
agentPagesMaxPackageBytes = int64(2 * 1024 * 1024 * 1024)
|
||||
agentPagesMaxFiles = 1000
|
||||
agentPagesMaxFileBytes = int64(8 * 1024 * 1024 * 1024)
|
||||
agentPagesMaxTotalBytes = int64(8 * 1024 * 1024 * 1024)
|
||||
// pagesLatestPullAttempts covers a race where the active deployment changes
|
||||
// between the hash probe and the package download.
|
||||
pagesLatestPullAttempts = 2
|
||||
)
|
||||
|
||||
type pagesSourceDocument struct {
|
||||
Routes []pagesSourceRoute `json:"routes"`
|
||||
}
|
||||
|
||||
type pagesSourceRoute struct {
|
||||
UpstreamType string `json:"upstream_type"`
|
||||
PagesProjectID *uint `json:"pages_project_id"`
|
||||
PagesDeployment *pagesDeploymentSource `json:"pages_deployment"`
|
||||
}
|
||||
|
||||
// pagesProjectRef is the agent-side "latest" pointer for one Pages project.
|
||||
type pagesProjectRef struct {
|
||||
ProjectID uint
|
||||
DeploymentID uint
|
||||
Checksum string
|
||||
}
|
||||
|
||||
type pagesPackageLimits struct {
|
||||
PackageBytes int64
|
||||
Extraction pagesarchive.Limits
|
||||
}
|
||||
|
||||
type pagesDeploymentMarker struct {
|
||||
ProjectID uint `json:"project_id"`
|
||||
DeploymentID uint `json:"deployment_id,omitempty"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
func pagesDeploymentStateHash(item state.PagesDeployment) string {
|
||||
if hash := strings.TrimSpace(item.Hash); hash != "" {
|
||||
return hash
|
||||
}
|
||||
return strings.TrimSpace(item.Checksum)
|
||||
}
|
||||
|
||||
func snapshotPagesProjects(snapshot *state.Snapshot) []pagesProjectRef {
|
||||
if snapshot == nil || snapshot.PagesDeployments == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]pagesProjectRef, 0, len(snapshot.PagesDeployments))
|
||||
for _, item := range snapshot.PagesDeployments {
|
||||
projectID := item.ProjectID
|
||||
if projectID == 0 {
|
||||
// Legacy agent state only stored deployment_id; skip until rediscovered from config.
|
||||
continue
|
||||
}
|
||||
result = append(result, pagesProjectRef{
|
||||
ProjectID: projectID,
|
||||
DeploymentID: item.DeploymentID,
|
||||
Checksum: pagesDeploymentStateHash(item),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func setSnapshotPagesProjects(snapshot *state.Snapshot, projects []pagesProjectRef) {
|
||||
if snapshot == nil {
|
||||
return
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
snapshot.PagesDeployments = []state.PagesDeployment{}
|
||||
return
|
||||
}
|
||||
snapshot.PagesDeployments = make([]state.PagesDeployment, len(projects))
|
||||
for i, project := range projects {
|
||||
snapshot.PagesDeployments[i] = state.PagesDeployment{
|
||||
ProjectID: project.ProjectID,
|
||||
DeploymentID: project.DeploymentID,
|
||||
Hash: strings.TrimSpace(project.Checksum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateSnapshotPagesProject(snapshot *state.Snapshot, project pagesProjectRef) {
|
||||
if snapshot == nil || snapshot.PagesDeployments == nil {
|
||||
return
|
||||
}
|
||||
hash := strings.TrimSpace(project.Checksum)
|
||||
for i := range snapshot.PagesDeployments {
|
||||
if snapshot.PagesDeployments[i].ProjectID != project.ProjectID {
|
||||
continue
|
||||
}
|
||||
snapshot.PagesDeployments[i].DeploymentID = project.DeploymentID
|
||||
snapshot.PagesDeployments[i].Hash = hash
|
||||
snapshot.PagesDeployments[i].Checksum = ""
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func pagesDiscoveryNeeded(snapshot *state.Snapshot) bool {
|
||||
if snapshot == nil || snapshot.PagesDeployments == nil {
|
||||
return true
|
||||
}
|
||||
// Legacy state rows may only have deployment_id (project_id == 0). Those
|
||||
// cannot poll latest-by-project; force a full config rediscovery.
|
||||
if len(snapshot.PagesDeployments) > 0 && len(snapshotPagesProjects(snapshot)) == 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pagesSyncNeeded(snapshot *state.Snapshot) bool {
|
||||
return snapshot != nil && len(snapshotPagesProjects(snapshot)) > 0
|
||||
}
|
||||
|
||||
func pagesReconcileNeeded(snapshot *state.Snapshot) bool {
|
||||
if pagesDiscoveryNeeded(snapshot) {
|
||||
return true
|
||||
}
|
||||
return pagesSyncNeeded(snapshot)
|
||||
}
|
||||
|
||||
func (s *Service) syncPagesDeployments(ctx context.Context, snapshot *state.Snapshot, config *protocol.ActiveConfigResponse) error {
|
||||
var projects []pagesProjectRef
|
||||
var err error
|
||||
if config != nil {
|
||||
projects, err = referencedPagesProjects(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setSnapshotPagesProjects(snapshot, projects)
|
||||
} else {
|
||||
projects = snapshotPagesProjects(snapshot)
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.pagesDir) == "" {
|
||||
return errors.New("pages_dir is required when active config references Pages projects")
|
||||
}
|
||||
|
||||
// Isolate per-project failures so one bad project does not block others.
|
||||
var failed []error
|
||||
for _, project := range projects {
|
||||
if ensureErr := s.ensurePagesProject(ctx, snapshot, project.ProjectID); ensureErr != nil {
|
||||
slog.Error("ensure Pages project failed",
|
||||
"project_id", project.ProjectID,
|
||||
"error", ensureErr,
|
||||
)
|
||||
failed = append(failed, fmt.Errorf("pages project %d: %w", project.ProjectID, ensureErr))
|
||||
}
|
||||
}
|
||||
if s.nginxManager != nil {
|
||||
if accessErr := s.nginxManager.EnsureWorkerReadAccess(); accessErr != nil {
|
||||
failed = append(failed, fmt.Errorf("ensure openresty worker read access: %w", accessErr))
|
||||
}
|
||||
}
|
||||
if len(failed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(failed...)
|
||||
}
|
||||
|
||||
// ensurePagesProject pulls the control-plane "latest" (active) package for a
|
||||
// Pages project and switches local current to that release when needed.
|
||||
// Only the latest release is retained on disk; older releases are removed after
|
||||
// the new release is ready and current has been switched.
|
||||
func (s *Service) ensurePagesProject(ctx context.Context, snapshot *state.Snapshot, projectID uint) error {
|
||||
if projectID == 0 {
|
||||
return errors.New("pages project id is required")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := range pagesLatestPullAttempts {
|
||||
latest, err := s.client.GetPagesProjectLatestHash(ctx, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch Pages project %d latest hash: %w", projectID, err)
|
||||
}
|
||||
limits, err := validatePagesPackageMetadata(projectID, latest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := strings.TrimSpace(latest.Hash)
|
||||
effective := pagesProjectRef{
|
||||
ProjectID: projectID,
|
||||
DeploymentID: latest.DeploymentID,
|
||||
Checksum: hash,
|
||||
}
|
||||
|
||||
releaseDir := pagesProjectReleaseDir(s.pagesDir, projectID, hash)
|
||||
if pagesProjectReleaseReady(releaseDir, effective) {
|
||||
if err := switchPagesProjectCurrentDir(s.pagesDir, projectID, releaseDir); err != nil {
|
||||
return err
|
||||
}
|
||||
updateSnapshotPagesProject(snapshot, effective)
|
||||
_ = cleanupPagesProjectStaleReleases(s.pagesDir, projectID, hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
packagePath, got, err := s.downloadPagesProjectPackage(ctx, projectID, latest, limits.PackageBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download Pages project %d latest package: %w", projectID, err)
|
||||
}
|
||||
|
||||
// Re-probe latest after download to detect activation races.
|
||||
// A deployment-id-only change is still a latest-pointer race even when
|
||||
// deduplication makes both deployments share the same package hash.
|
||||
verify, err := s.client.GetPagesProjectLatestHash(ctx, projectID)
|
||||
if err != nil {
|
||||
_ = os.Remove(packagePath)
|
||||
return fmt.Errorf("re-fetch Pages project %d latest hash: %w", projectID, err)
|
||||
}
|
||||
if _, err := validatePagesPackageMetadata(projectID, verify); err != nil {
|
||||
_ = os.Remove(packagePath)
|
||||
return err
|
||||
}
|
||||
if !samePagesPackageMetadata(latest, verify) {
|
||||
_ = os.Remove(packagePath)
|
||||
lastErr = fmt.Errorf(
|
||||
"pages project %d latest metadata changed during download: deployment %d/%s -> %d/%s (attempt %d/%d)",
|
||||
projectID,
|
||||
latest.DeploymentID,
|
||||
strings.TrimSpace(latest.Hash),
|
||||
verify.DeploymentID,
|
||||
strings.TrimSpace(verify.Hash),
|
||||
attempt+1,
|
||||
pagesLatestPullAttempts,
|
||||
)
|
||||
slog.Warn("pages latest metadata race, retrying",
|
||||
"project_id", projectID,
|
||||
"before_deployment_id", latest.DeploymentID,
|
||||
"before_hash", strings.TrimSpace(latest.Hash),
|
||||
"after_deployment_id", verify.DeploymentID,
|
||||
"after_hash", strings.TrimSpace(verify.Hash),
|
||||
"attempt", attempt+1,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if got != hash {
|
||||
_ = os.Remove(packagePath)
|
||||
lastErr = fmt.Errorf(
|
||||
"pages project %d package hash mismatch: downloaded %s, expected %s (attempt %d/%d)",
|
||||
projectID, got, hash, attempt+1, pagesLatestPullAttempts,
|
||||
)
|
||||
slog.Warn("pages latest package hash mismatch, retrying",
|
||||
"project_id", projectID,
|
||||
"downloaded_hash", got,
|
||||
"expected_hash", hash,
|
||||
"attempt", attempt+1,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
releaseDir = pagesProjectReleaseDir(s.pagesDir, projectID, got)
|
||||
extractErr := extractPagesPackageFile(packagePath, releaseDir, effective, limits.Extraction, latest)
|
||||
_ = os.Remove(packagePath)
|
||||
if extractErr != nil {
|
||||
return extractErr
|
||||
}
|
||||
if err := switchPagesProjectCurrentDir(s.pagesDir, projectID, releaseDir); err != nil {
|
||||
return err
|
||||
}
|
||||
updateSnapshotPagesProject(snapshot, effective)
|
||||
// Only after the new release is ready and current switched: drop others.
|
||||
_ = cleanupPagesProjectStaleReleases(s.pagesDir, projectID, got)
|
||||
return nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return fmt.Errorf("pages project %d latest pull failed", projectID)
|
||||
}
|
||||
|
||||
func validatePagesPackageMetadata(
|
||||
projectID uint,
|
||||
metadata *protocol.PagesProjectLatestHashResponse,
|
||||
) (pagesPackageLimits, error) {
|
||||
if metadata == nil {
|
||||
return pagesPackageLimits{}, fmt.Errorf("pages project %d latest metadata is missing", projectID)
|
||||
}
|
||||
if metadata.ProjectID != projectID {
|
||||
return pagesPackageLimits{}, fmt.Errorf(
|
||||
"pages project %d latest metadata has project id %d",
|
||||
projectID,
|
||||
metadata.ProjectID,
|
||||
)
|
||||
}
|
||||
if metadata.DeploymentID == 0 {
|
||||
return pagesPackageLimits{}, fmt.Errorf("pages project %d latest deployment id is missing", projectID)
|
||||
}
|
||||
if strings.TrimSpace(metadata.Hash) == "" {
|
||||
return pagesPackageLimits{}, fmt.Errorf("pages project %d latest hash is empty", projectID)
|
||||
}
|
||||
if metadata.PackageSize <= 0 {
|
||||
return pagesPackageLimits{}, fmt.Errorf("pages project %d package size must be positive", projectID)
|
||||
}
|
||||
if metadata.PackageSize > agentPagesMaxPackageBytes {
|
||||
return pagesPackageLimits{}, fmt.Errorf(
|
||||
"pages project %d package size %d exceeds agent limit %d",
|
||||
projectID,
|
||||
metadata.PackageSize,
|
||||
agentPagesMaxPackageBytes,
|
||||
)
|
||||
}
|
||||
if metadata.FileCount <= 0 {
|
||||
return pagesPackageLimits{}, fmt.Errorf("pages project %d file count must be positive", projectID)
|
||||
}
|
||||
if metadata.FileCount > agentPagesMaxFiles {
|
||||
return pagesPackageLimits{}, fmt.Errorf(
|
||||
"pages project %d file count %d exceeds agent limit %d",
|
||||
projectID,
|
||||
metadata.FileCount,
|
||||
agentPagesMaxFiles,
|
||||
)
|
||||
}
|
||||
if metadata.TotalSize < 0 {
|
||||
return pagesPackageLimits{}, fmt.Errorf("pages project %d total size cannot be negative", projectID)
|
||||
}
|
||||
if metadata.TotalSize > agentPagesMaxTotalBytes {
|
||||
return pagesPackageLimits{}, fmt.Errorf(
|
||||
"pages project %d total size %d exceeds agent limit %d",
|
||||
projectID,
|
||||
metadata.TotalSize,
|
||||
agentPagesMaxTotalBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// pagesarchive treats zero limits as defaults. A one-byte extraction guard
|
||||
// plus the exact post-extraction manifest check below preserves the valid
|
||||
// case of one or more zero-byte files while still enforcing total_size=0.
|
||||
extractedBytes := metadata.TotalSize
|
||||
if extractedBytes == 0 {
|
||||
extractedBytes = 1
|
||||
}
|
||||
maxFileBytes := min(extractedBytes, agentPagesMaxFileBytes)
|
||||
|
||||
return pagesPackageLimits{
|
||||
PackageBytes: metadata.PackageSize,
|
||||
Extraction: pagesarchive.Limits{
|
||||
MaxFiles: metadata.FileCount,
|
||||
MaxFileBytes: maxFileBytes,
|
||||
MaxTotalBytes: extractedBytes,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func samePagesPackageMetadata(
|
||||
before *protocol.PagesProjectLatestHashResponse,
|
||||
after *protocol.PagesProjectLatestHashResponse,
|
||||
) bool {
|
||||
if before == nil || after == nil {
|
||||
return false
|
||||
}
|
||||
return before.ProjectID == after.ProjectID &&
|
||||
before.DeploymentID == after.DeploymentID &&
|
||||
strings.TrimSpace(before.Hash) == strings.TrimSpace(after.Hash) &&
|
||||
before.PackageSize == after.PackageSize &&
|
||||
before.FileCount == after.FileCount &&
|
||||
before.TotalSize == after.TotalSize
|
||||
}
|
||||
|
||||
func (s *Service) downloadPagesProjectPackage(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
metadata *protocol.PagesProjectLatestHashResponse,
|
||||
maxBytes int64,
|
||||
) (packagePath string, hash string, err error) {
|
||||
releasesRoot := filepath.Join(s.pagesDir, "projects", strconv.FormatUint(uint64(projectID), 10), "releases")
|
||||
if err := os.MkdirAll(releasesRoot, pagesDirPerm); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
packageFile, err := os.CreateTemp(releasesRoot, ".package-*.tmp")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
packagePath = packageFile.Name()
|
||||
keep := false
|
||||
defer func() {
|
||||
if closeErr := packageFile.Close(); err == nil && closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
if !keep || err != nil {
|
||||
_ = os.Remove(packagePath)
|
||||
packagePath = ""
|
||||
}
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
written, err := s.client.DownloadPagesProjectLatestPackage(
|
||||
ctx,
|
||||
projectID,
|
||||
io.MultiWriter(packageFile, hasher),
|
||||
maxBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if written != metadata.PackageSize {
|
||||
return "", "", fmt.Errorf(
|
||||
"pages project %d package size %d does not match metadata %d",
|
||||
projectID,
|
||||
written,
|
||||
metadata.PackageSize,
|
||||
)
|
||||
}
|
||||
keep = true
|
||||
return packagePath, hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// cleanupPagesProjectStaleReleases keeps only keepHash under projects/{id}/releases.
|
||||
// Must be called only after the keepHash release is ready and current points at it.
|
||||
func cleanupPagesProjectStaleReleases(baseDir string, projectID uint, keepHash string) error {
|
||||
keepHash = strings.TrimSpace(keepHash)
|
||||
if projectID == 0 || keepHash == "" {
|
||||
return nil
|
||||
}
|
||||
releasesRoot := filepath.Join(baseDir, "projects", strconv.FormatUint(uint64(projectID), 10), "releases")
|
||||
entries, err := os.ReadDir(releasesRoot) //nolint:gosec // managed PagesDir
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == keepHash {
|
||||
continue
|
||||
}
|
||||
// Drop partial extract leftovers as well (*.tmp).
|
||||
target := filepath.Join(releasesRoot, name)
|
||||
if removeErr := os.RemoveAll(target); removeErr != nil && firstErr == nil {
|
||||
firstErr = removeErr
|
||||
slog.Warn("failed to remove stale Pages release",
|
||||
"project_id", projectID,
|
||||
"path", target,
|
||||
"error", removeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Also remove legacy deployments/ tree leftovers if present (best-effort).
|
||||
_ = os.RemoveAll(filepath.Join(baseDir, "deployments"))
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func pagesProjectReleaseReady(dir string, project pagesProjectRef) bool {
|
||||
if !markerMatches(dir, project) {
|
||||
return false
|
||||
}
|
||||
entries, err := os.ReadDir(dir) //nolint:gosec // dir is managed PagesDir
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == ".openflare-pages.json" {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func referencedPagesProjects(config *protocol.ActiveConfigResponse) ([]pagesProjectRef, error) {
|
||||
if config == nil || strings.TrimSpace(config.SourceConfigJSON) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var doc pagesSourceDocument
|
||||
if err := json.Unmarshal([]byte(config.SourceConfigJSON), &doc); err != nil {
|
||||
return nil, fmt.Errorf("decode pages references: %w", err)
|
||||
}
|
||||
seen := make(map[uint]struct{})
|
||||
result := make([]pagesProjectRef, 0)
|
||||
for _, route := range doc.Routes {
|
||||
if strings.ToLower(strings.TrimSpace(route.UpstreamType)) != "pages" {
|
||||
continue
|
||||
}
|
||||
projectID := pagesProjectIDFromRoute(route)
|
||||
if projectID == 0 {
|
||||
return nil, errors.New("pages route is missing project_id")
|
||||
}
|
||||
if _, ok := seen[projectID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[projectID] = struct{}{}
|
||||
checksum := ""
|
||||
deploymentID := uint(0)
|
||||
if route.PagesDeployment != nil {
|
||||
checksum = strings.TrimSpace(route.PagesDeployment.Checksum)
|
||||
deploymentID = route.PagesDeployment.DeploymentID
|
||||
}
|
||||
result = append(result, pagesProjectRef{
|
||||
ProjectID: projectID,
|
||||
DeploymentID: deploymentID,
|
||||
Checksum: checksum,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func pagesProjectIDFromRoute(route pagesSourceRoute) uint {
|
||||
if route.PagesProjectID != nil && *route.PagesProjectID != 0 {
|
||||
return *route.PagesProjectID
|
||||
}
|
||||
if route.PagesDeployment != nil && route.PagesDeployment.ProjectID != 0 {
|
||||
return route.PagesDeployment.ProjectID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// pagesDeploymentSource is the subset of pages_deployment used when parsing config.
|
||||
type pagesDeploymentSource struct {
|
||||
ProjectID uint `json:"project_id"`
|
||||
DeploymentID uint `json:"deployment_id"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
func extractPagesPackageFile(
|
||||
packagePath string,
|
||||
releaseDir string,
|
||||
project pagesProjectRef,
|
||||
limits pagesarchive.Limits,
|
||||
expected *protocol.PagesProjectLatestHashResponse,
|
||||
) error {
|
||||
if err := os.MkdirAll(filepath.Dir(releaseDir), pagesDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
stagingDir, err := os.MkdirTemp(
|
||||
filepath.Dir(releaseDir),
|
||||
"."+filepath.Base(releaseDir)+"-*.tmp",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupStaging := true
|
||||
defer func() {
|
||||
if cleanupStaging {
|
||||
removePagesStagingUnlessCurrent(stagingDir, pagesCurrentDirFromRelease(releaseDir))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := pagesarchive.ExtractFile(packagePath, "", stagingDir, pagesarchive.ExtractOptions{
|
||||
StripCommonRoot: true,
|
||||
EnforceLimits: true,
|
||||
Limits: limits,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("extract Pages package: %w", err)
|
||||
}
|
||||
if err := validateExtractedPagesMetadata(stagingDir, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePagesMarker(stagingDir, project); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := promotePagesRelease(stagingDir, releaseDir, project); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupStaging = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExtractedPagesMetadata(
|
||||
dir string,
|
||||
expected *protocol.PagesProjectLatestHashResponse,
|
||||
) error {
|
||||
if expected == nil {
|
||||
return nil
|
||||
}
|
||||
fileCount := 0
|
||||
totalSize := int64(0)
|
||||
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("pages extracted entry is not a regular file: %s", path)
|
||||
}
|
||||
fileCount++
|
||||
if fileCount > agentPagesMaxFiles {
|
||||
return fmt.Errorf("pages extracted file count exceeds agent limit %d", agentPagesMaxFiles)
|
||||
}
|
||||
if info.Size() < 0 || info.Size() > agentPagesMaxTotalBytes-totalSize {
|
||||
return fmt.Errorf("pages extracted size exceeds agent limit %d", agentPagesMaxTotalBytes)
|
||||
}
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate extracted Pages package: %w", err)
|
||||
}
|
||||
if fileCount != expected.FileCount || totalSize != expected.TotalSize {
|
||||
return fmt.Errorf(
|
||||
"pages extracted metadata mismatch: got %d files/%d bytes, expected %d files/%d bytes",
|
||||
fileCount,
|
||||
totalSize,
|
||||
expected.FileCount,
|
||||
expected.TotalSize,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promotePagesRelease(stagingDir string, releaseDir string, project pagesProjectRef) error {
|
||||
return promotePagesReleaseWithCopy(stagingDir, releaseDir, project, copyPagesDir)
|
||||
}
|
||||
|
||||
func promotePagesReleaseWithCopy(
|
||||
stagingDir string,
|
||||
releaseDir string,
|
||||
project pagesProjectRef,
|
||||
copyDir func(string, string) error,
|
||||
) error {
|
||||
currentDir := pagesCurrentDirFromRelease(releaseDir)
|
||||
defer removePagesStagingUnlessCurrent(stagingDir, currentDir)
|
||||
currentUsesRelease, err := pagesCurrentTargetsRelease(currentDir, releaseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !currentUsesRelease {
|
||||
if err := os.RemoveAll(releaseDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(stagingDir, releaseDir)
|
||||
}
|
||||
|
||||
// A same-hash repair cannot remove releaseDir while current still resolves
|
||||
// through it. Keep traffic on the fully validated staging tree, rebuild the
|
||||
// canonical release, then atomically point current back to the canonical path.
|
||||
if err := switchPagesCurrentDir(currentDir, stagingDir, os.Rename); err != nil {
|
||||
return fmt.Errorf("switch Pages current to repair staging: %w", err)
|
||||
}
|
||||
backupDir := stagingDir + ".previous"
|
||||
if err := os.Rename(releaseDir, backupDir); err != nil {
|
||||
restoreErr := switchPagesCurrentDir(currentDir, releaseDir, os.Rename)
|
||||
return errors.Join(
|
||||
fmt.Errorf("move previous Pages release aside: %w", err),
|
||||
restoreErr,
|
||||
)
|
||||
}
|
||||
|
||||
rollback := func(cause error) error {
|
||||
var rollbackErrors []error
|
||||
rollbackErrors = append(rollbackErrors, cause)
|
||||
if err := os.RemoveAll(releaseDir); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("remove failed Pages release repair: %w", err))
|
||||
}
|
||||
if err := os.Rename(backupDir, releaseDir); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("restore previous Pages release: %w", err))
|
||||
return errors.Join(rollbackErrors...)
|
||||
}
|
||||
if err := switchPagesCurrentDir(currentDir, releaseDir, os.Rename); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("restore previous Pages current target: %w", err))
|
||||
}
|
||||
return errors.Join(rollbackErrors...)
|
||||
}
|
||||
|
||||
if err := copyDir(stagingDir, releaseDir); err != nil {
|
||||
return rollback(fmt.Errorf("copy repaired Pages release: %w", err))
|
||||
}
|
||||
if !pagesProjectReleaseReady(releaseDir, project) {
|
||||
return rollback(errors.New("repaired Pages release is not ready"))
|
||||
}
|
||||
if err := switchPagesCurrentDir(currentDir, releaseDir, os.Rename); err != nil {
|
||||
return rollback(fmt.Errorf("switch Pages current to repaired release: %w", err))
|
||||
}
|
||||
if err := os.RemoveAll(backupDir); err != nil {
|
||||
slog.Warn("failed to remove previous Pages release", "path", backupDir, "error", err)
|
||||
}
|
||||
if err := os.RemoveAll(stagingDir); err != nil {
|
||||
slog.Warn("failed to remove Pages repair staging", "path", stagingDir, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pagesCurrentDirFromRelease(releaseDir string) string {
|
||||
return filepath.Join(filepath.Dir(filepath.Dir(releaseDir)), "current")
|
||||
}
|
||||
|
||||
func pagesCurrentTargetsRelease(currentDir string, releaseDir string) (bool, error) {
|
||||
if _, err := os.Lstat(currentDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
currentInfo, err := os.Stat(currentDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("stat Pages current target: %w", err)
|
||||
}
|
||||
releaseInfo, err := os.Stat(releaseDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return os.SameFile(currentInfo, releaseInfo), nil
|
||||
}
|
||||
|
||||
func removePagesStagingUnlessCurrent(stagingDir string, currentDir string) {
|
||||
currentUsesStaging, err := pagesCurrentTargetsRelease(currentDir, stagingDir)
|
||||
if err == nil && currentUsesStaging {
|
||||
slog.Error("preserving Pages staging because current still references it", "path", stagingDir)
|
||||
return
|
||||
}
|
||||
if removeErr := os.RemoveAll(stagingDir); removeErr != nil {
|
||||
slog.Warn("failed to remove Pages staging", "path", stagingDir, "error", removeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyPagesCurrentTarget(currentDir string, releaseDir string) error {
|
||||
currentInfo, err := os.Stat(currentDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat Pages current target: %w", err)
|
||||
}
|
||||
releaseInfo, err := os.Stat(releaseDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat Pages release target: %w", err)
|
||||
}
|
||||
if !os.SameFile(currentInfo, releaseInfo) {
|
||||
return fmt.Errorf("pages current target does not resolve to release %s", releaseDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func switchPagesCurrentDir(
|
||||
currentDir string,
|
||||
releaseDir string,
|
||||
rename func(string, string) error,
|
||||
) error {
|
||||
return switchPagesCurrentDirWithOps(currentDir, releaseDir, rename, os.Symlink)
|
||||
}
|
||||
|
||||
func switchPagesCurrentDirWithOps(
|
||||
currentDir string,
|
||||
releaseDir string,
|
||||
rename func(string, string) error,
|
||||
symlink func(string, string) error,
|
||||
) error {
|
||||
if err := os.MkdirAll(filepath.Dir(currentDir), pagesDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
currentInfo, currentErr := os.Lstat(currentDir)
|
||||
if currentErr != nil && !os.IsNotExist(currentErr) {
|
||||
return currentErr
|
||||
}
|
||||
if currentErr == nil && currentInfo.Mode()&os.ModeSymlink == 0 {
|
||||
return fallbackCopyPagesCurrentDir(currentDir, releaseDir, rename)
|
||||
}
|
||||
|
||||
previousTarget := ""
|
||||
hadPrevious := currentErr == nil
|
||||
if hadPrevious {
|
||||
var err error
|
||||
previousTarget, err = os.Readlink(currentDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
relTarget, err := filepath.Rel(filepath.Dir(currentDir), releaseDir)
|
||||
if err != nil {
|
||||
relTarget = releaseDir
|
||||
}
|
||||
|
||||
tmpSymlink := currentDir + ".tmp"
|
||||
if err := os.Remove(tmpSymlink); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := symlink(relTarget, tmpSymlink); err != nil {
|
||||
_ = os.Remove(tmpSymlink)
|
||||
return fallbackCopyPagesCurrentDir(currentDir, releaseDir, rename)
|
||||
}
|
||||
defer func() { _ = os.Remove(tmpSymlink) }()
|
||||
if err := verifyPagesCurrentTarget(tmpSymlink, releaseDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rename(tmpSymlink, currentDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyPagesCurrentTarget(currentDir, releaseDir); err != nil {
|
||||
rollbackErr := rollbackPagesCurrentSymlink(
|
||||
currentDir,
|
||||
previousTarget,
|
||||
hadPrevious,
|
||||
rename,
|
||||
)
|
||||
return errors.Join(err, rollbackErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fallbackCopyPagesCurrentDir(
|
||||
currentDir string,
|
||||
releaseDir string,
|
||||
rename func(string, string) error,
|
||||
) error {
|
||||
stagingDir := currentDir + ".copy.tmp"
|
||||
previousDir := currentDir + ".previous"
|
||||
if err := os.RemoveAll(stagingDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyPagesDir(releaseDir, stagingDir); err != nil {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(previousDir); err != nil {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
return err
|
||||
}
|
||||
|
||||
hadPrevious := false
|
||||
if _, err := os.Lstat(currentDir); err == nil {
|
||||
if err := rename(currentDir, previousDir); err != nil {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
return err
|
||||
}
|
||||
hadPrevious = true
|
||||
} else if !os.IsNotExist(err) {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
return err
|
||||
}
|
||||
if err := rename(stagingDir, currentDir); err != nil {
|
||||
var restoreErr error
|
||||
if hadPrevious {
|
||||
restoreErr = rename(previousDir, currentDir)
|
||||
}
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
return errors.Join(err, restoreErr)
|
||||
}
|
||||
if err := os.RemoveAll(previousDir); err != nil {
|
||||
slog.Warn("failed to remove previous Pages current directory", "path", previousDir, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func switchPagesProjectCurrentDir(baseDir string, projectID uint, releaseDir string) error {
|
||||
return switchPagesProjectCurrentDirWithRename(baseDir, projectID, releaseDir, os.Rename)
|
||||
}
|
||||
|
||||
func switchPagesProjectCurrentDirWithRename(
|
||||
baseDir string,
|
||||
projectID uint,
|
||||
releaseDir string,
|
||||
rename func(string, string) error,
|
||||
) error {
|
||||
return switchPagesCurrentDir(pagesProjectCurrentDir(baseDir, projectID), releaseDir, rename)
|
||||
}
|
||||
|
||||
func rollbackPagesCurrentSymlink(
|
||||
currentDir string,
|
||||
previousTarget string,
|
||||
hadPrevious bool,
|
||||
rename func(string, string) error,
|
||||
) error {
|
||||
if !hadPrevious {
|
||||
if err := os.Remove(currentDir); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove unverified Pages current symlink: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rollbackSymlink := currentDir + ".rollback.tmp"
|
||||
if err := os.Remove(rollbackSymlink); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(previousTarget, rollbackSymlink); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.Remove(rollbackSymlink) }()
|
||||
if err := rename(rollbackSymlink, currentDir); err != nil {
|
||||
return fmt.Errorf("restore previous Pages current symlink: %w", err)
|
||||
}
|
||||
gotTarget, err := os.Readlink(currentDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify restored Pages current symlink: %w", err)
|
||||
}
|
||||
if gotTarget != previousTarget {
|
||||
return fmt.Errorf(
|
||||
"restored Pages current symlink target %q does not match %q",
|
||||
gotTarget,
|
||||
previousTarget,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyPagesDir(sourceDir string, targetDir string) error {
|
||||
return filepath.WalkDir(sourceDir, func(sourcePath string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relativePath, err := filepath.Rel(sourceDir, sourcePath)
|
||||
if err != nil || relativePath == "." {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(targetDir, relativePath)
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(targetPath, pagesDirPerm)
|
||||
}
|
||||
return copyPagesFile(sourcePath, targetPath)
|
||||
})
|
||||
}
|
||||
|
||||
func copyPagesFile(sourcePath string, targetPath string) error {
|
||||
input, err := os.Open(sourcePath) //nolint:gosec // sourcePath is under managed PagesDir walk root
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), pagesDirPerm); err != nil {
|
||||
_ = input.Close()
|
||||
return err
|
||||
}
|
||||
output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, pagesFilePerm) //nolint:gosec // targetPath is under managed PagesDir walk root
|
||||
if err != nil {
|
||||
_ = input.Close()
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(output, input)
|
||||
outputCloseErr := output.Close()
|
||||
inputCloseErr := input.Close()
|
||||
return errors.Join(copyErr, outputCloseErr, inputCloseErr)
|
||||
}
|
||||
|
||||
func markerMatches(dir string, project pagesProjectRef) bool {
|
||||
data, err := os.ReadFile(filepath.Join(dir, ".openflare-pages.json")) //nolint:gosec // dir is managed PagesDir
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var marker pagesDeploymentMarker
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
return false
|
||||
}
|
||||
if marker.ProjectID != 0 && marker.ProjectID != project.ProjectID {
|
||||
return false
|
||||
}
|
||||
return marker.Checksum == project.Checksum
|
||||
}
|
||||
|
||||
func writePagesMarker(dir string, project pagesProjectRef) error {
|
||||
data, err := json.Marshal(pagesDeploymentMarker(project))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, ".openflare-pages.json"), data, pagesManifestFilePerm)
|
||||
}
|
||||
|
||||
func pagesProjectCurrentDir(baseDir string, projectID uint) string {
|
||||
return filepath.Join(baseDir, "projects", strconv.FormatUint(uint64(projectID), 10), "current")
|
||||
}
|
||||
|
||||
func pagesProjectReleaseDir(baseDir string, projectID uint, checksum string) string {
|
||||
return filepath.Join(baseDir, "projects", strconv.FormatUint(uint64(projectID), 10), "releases", checksum)
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
)
|
||||
|
||||
func TestEnsurePagesProjectRejectsMetadataBeyondAgentCapsBeforeDownload(t *testing.T) {
|
||||
packageBytes := testPagesPackage(t, map[string]string{"index.html": "x"})
|
||||
base := protocol.PagesProjectLatestHashResponse{
|
||||
ProjectID: 1,
|
||||
DeploymentID: 1,
|
||||
Hash: testBytesChecksum(packageBytes),
|
||||
PackageSize: int64(len(packageBytes)),
|
||||
FileCount: 1,
|
||||
TotalSize: 1,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*protocol.PagesProjectLatestHashResponse)
|
||||
}{
|
||||
{
|
||||
name: "package size",
|
||||
mutate: func(metadata *protocol.PagesProjectLatestHashResponse) {
|
||||
metadata.PackageSize = agentPagesMaxPackageBytes + 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file count",
|
||||
mutate: func(metadata *protocol.PagesProjectLatestHashResponse) {
|
||||
metadata.FileCount = agentPagesMaxFiles + 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "total size",
|
||||
mutate: func(metadata *protocol.PagesProjectLatestHashResponse) {
|
||||
metadata.TotalSize = agentPagesMaxTotalBytes + 1
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
metadata := base
|
||||
test.mutate(&metadata)
|
||||
client := &fakeClient{
|
||||
pagesPackages: map[uint][]byte{1: packageBytes},
|
||||
pagesMetadata: map[uint]protocol.PagesProjectLatestHashResponse{1: metadata},
|
||||
}
|
||||
service := New(client, &fakeManager{}, nil)
|
||||
service.SetPagesDir(t.TempDir())
|
||||
err := service.ensurePagesProject(context.Background(), &state.Snapshot{}, 1)
|
||||
if err == nil || !strings.Contains(err.Error(), "agent limit") {
|
||||
t.Fatalf("ensurePagesProject(%s metadata) error = %v, want agent limit error", test.name, err)
|
||||
}
|
||||
if client.pagesPackageDownloads != 0 {
|
||||
t.Errorf("ensurePagesProject(%s metadata) downloads = %d, want 0", test.name, client.pagesPackageDownloads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePagesProjectRetriesSameHashDifferentDeployment(t *testing.T) {
|
||||
packageBytes := testPagesPackage(t, map[string]string{"index.html": "same"})
|
||||
hash := testBytesChecksum(packageBytes)
|
||||
client := &racingLatestClient{
|
||||
pkgA: packageBytes,
|
||||
pkgB: packageBytes,
|
||||
hashA: hash,
|
||||
hashB: hash,
|
||||
}
|
||||
service := New(client, &fakeManager{}, nil)
|
||||
pagesDir := t.TempDir()
|
||||
service.SetPagesDir(pagesDir)
|
||||
snapshot := &state.Snapshot{PagesDeployments: []state.PagesDeployment{{ProjectID: 42}}}
|
||||
|
||||
if err := service.ensurePagesProject(context.Background(), snapshot, 42); err != nil {
|
||||
t.Fatalf("ensurePagesProject(same hash deployment race) error = %v", err)
|
||||
}
|
||||
if client.downloadCalls != 2 {
|
||||
t.Errorf("ensurePagesProject(same hash deployment race) downloads = %d, want 2", client.downloadCalls)
|
||||
}
|
||||
if snapshot.PagesDeployments[0].DeploymentID != 2 || snapshot.PagesDeployments[0].Hash != hash {
|
||||
t.Errorf("snapshot Pages deployment = %+v, want deployment 2/hash %s", snapshot.PagesDeployments[0], hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePagesProjectExtractionFailureCleansTempAndPreservesCurrent(t *testing.T) {
|
||||
projectID := uint(9)
|
||||
oldPackage := testPagesPackage(t, map[string]string{"index.html": "old"})
|
||||
oldHash := testBytesChecksum(oldPackage)
|
||||
newPackage := testPagesPackage(t, map[string]string{"index.html": "new"})
|
||||
newHash := testBytesChecksum(newPackage)
|
||||
pagesDir := t.TempDir()
|
||||
oldRelease := pagesProjectReleaseDir(pagesDir, projectID, oldHash)
|
||||
if err := extractTestPagesPackage(t, oldPackage, oldRelease, pagesProjectRef{
|
||||
ProjectID: projectID,
|
||||
DeploymentID: 1,
|
||||
Checksum: oldHash,
|
||||
}); err != nil {
|
||||
t.Fatalf("extractTestPagesPackage(old) error = %v", err)
|
||||
}
|
||||
if err := switchPagesProjectCurrentDir(pagesDir, projectID, oldRelease); err != nil {
|
||||
t.Fatalf("switchPagesProjectCurrentDir(old) error = %v", err)
|
||||
}
|
||||
|
||||
client := &fakeClient{
|
||||
pagesPackages: map[uint][]byte{projectID: newPackage},
|
||||
pagesMetadata: map[uint]protocol.PagesProjectLatestHashResponse{
|
||||
projectID: {
|
||||
ProjectID: projectID,
|
||||
DeploymentID: 2,
|
||||
Hash: newHash,
|
||||
PackageSize: int64(len(newPackage)),
|
||||
FileCount: 1,
|
||||
TotalSize: 2, // Smaller than the actual three-byte file.
|
||||
},
|
||||
},
|
||||
}
|
||||
service := New(client, &fakeManager{}, nil)
|
||||
service.SetPagesDir(pagesDir)
|
||||
err := service.ensurePagesProject(context.Background(), &state.Snapshot{}, projectID)
|
||||
if err == nil {
|
||||
t.Fatal("ensurePagesProject(metadata-tightened extraction) error = nil, want error")
|
||||
}
|
||||
current, readErr := os.ReadFile(pagesProjectCurrentDir(pagesDir, projectID) + "/index.html")
|
||||
if readErr != nil {
|
||||
t.Fatalf("read old current after failed extraction error = %v", readErr)
|
||||
}
|
||||
if string(current) != "old" {
|
||||
t.Errorf("current content after failed extraction = %q, want %q", current, "old")
|
||||
}
|
||||
entries, readErr := os.ReadDir(filepath.Join(pagesDir, "projects", "9", "releases"))
|
||||
if readErr != nil {
|
||||
t.Fatalf("read releases after failed extraction error = %v", readErr)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != oldHash {
|
||||
t.Errorf("releases after failed extraction = %v, want only %s", entries, oldHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePagesProjectAcceptsAllZeroByteFiles(t *testing.T) {
|
||||
packageBytes := testPagesPackage(t, map[string]string{
|
||||
"index.html": "",
|
||||
".gitkeep": "",
|
||||
})
|
||||
client := &fakeClient{pagesPackages: map[uint][]byte{5: packageBytes}}
|
||||
service := New(client, &fakeManager{}, nil)
|
||||
pagesDir := t.TempDir()
|
||||
service.SetPagesDir(pagesDir)
|
||||
|
||||
if err := service.ensurePagesProject(context.Background(), &state.Snapshot{}, 5); err != nil {
|
||||
t.Fatalf("ensurePagesProject(all-zero files) error = %v", err)
|
||||
}
|
||||
for _, name := range []string{"index.html", ".gitkeep"} {
|
||||
info, err := os.Stat(filepath.Join(pagesProjectCurrentDir(pagesDir, 5), name))
|
||||
if err != nil {
|
||||
t.Errorf("stat all-zero file %q error = %v", name, err)
|
||||
continue
|
||||
}
|
||||
if info.Size() != 0 {
|
||||
t.Errorf("all-zero file %q size = %d, want 0", name, info.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchPagesProjectCurrentDirRenameFailureKeepsPreviousCurrent(t *testing.T) {
|
||||
pagesDir := t.TempDir()
|
||||
projectID := uint(21)
|
||||
oldRelease := pagesProjectReleaseDir(pagesDir, projectID, "old")
|
||||
newRelease := pagesProjectReleaseDir(pagesDir, projectID, "new")
|
||||
for path, content := range map[string]string{
|
||||
oldRelease: "old",
|
||||
newRelease: "new",
|
||||
} {
|
||||
if err := os.MkdirAll(path, pagesDirPerm); err != nil {
|
||||
t.Fatalf("mkdir release %q error = %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "index.html"), []byte(content), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write release %q error = %v", path, err)
|
||||
}
|
||||
}
|
||||
if err := switchPagesProjectCurrentDir(pagesDir, projectID, oldRelease); err != nil {
|
||||
t.Fatalf("seed previous current error = %v", err)
|
||||
}
|
||||
currentDir := pagesProjectCurrentDir(pagesDir, projectID)
|
||||
renameErr := errors.New("injected current rename failure")
|
||||
err := switchPagesProjectCurrentDirWithRename(
|
||||
pagesDir,
|
||||
projectID,
|
||||
newRelease,
|
||||
func(oldPath string, newPath string) error {
|
||||
if oldPath == currentDir+".tmp" && newPath == currentDir {
|
||||
return renameErr
|
||||
}
|
||||
return os.Rename(oldPath, newPath)
|
||||
},
|
||||
)
|
||||
if !errors.Is(err, renameErr) {
|
||||
t.Fatalf("switchPagesProjectCurrentDirWithRename() error = %v, want injected rename error", err)
|
||||
}
|
||||
current, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read previous current after rename failure error = %v", err)
|
||||
}
|
||||
if string(current) != "old" {
|
||||
t.Errorf("current after rename failure = %q, want %q", current, "old")
|
||||
}
|
||||
if _, err := os.Lstat(currentDir + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Errorf("temporary current symlink remains after rename failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteSameHashReleaseFailureRestoresPreviousCurrent(t *testing.T) {
|
||||
pagesDir := t.TempDir()
|
||||
projectID := uint(22)
|
||||
hash := "same-hash"
|
||||
project := pagesProjectRef{ProjectID: projectID, DeploymentID: 2, Checksum: hash}
|
||||
releaseDir := pagesProjectReleaseDir(pagesDir, projectID, hash)
|
||||
if err := os.MkdirAll(releaseDir, pagesDirPerm); err != nil {
|
||||
t.Fatalf("mkdir previous same-hash release error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("old"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write previous same-hash release error = %v", err)
|
||||
}
|
||||
if err := writePagesMarker(releaseDir, project); err != nil {
|
||||
t.Fatalf("write previous same-hash marker error = %v", err)
|
||||
}
|
||||
if err := switchPagesProjectCurrentDir(pagesDir, projectID, releaseDir); err != nil {
|
||||
t.Fatalf("seed same-hash current error = %v", err)
|
||||
}
|
||||
stagingDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stagingDir, "index.html"), []byte("new"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write repaired same-hash release error = %v", err)
|
||||
}
|
||||
if err := writePagesMarker(stagingDir, project); err != nil {
|
||||
t.Fatalf("write repaired same-hash marker error = %v", err)
|
||||
}
|
||||
copyErr := errors.New("injected same-hash copy failure")
|
||||
err := promotePagesReleaseWithCopy(
|
||||
stagingDir,
|
||||
releaseDir,
|
||||
project,
|
||||
func(_ string, targetDir string) error {
|
||||
if err := os.MkdirAll(targetDir, pagesDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(targetDir, "index.html"), []byte("partial"), pagesFilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return copyErr
|
||||
},
|
||||
)
|
||||
if !errors.Is(err, copyErr) {
|
||||
t.Fatalf("promotePagesReleaseWithCopy() error = %v, want injected copy error", err)
|
||||
}
|
||||
for name, path := range map[string]string{
|
||||
"current": filepath.Join(pagesProjectCurrentDir(pagesDir, projectID), "index.html"),
|
||||
"release": filepath.Join(releaseDir, "index.html"),
|
||||
} {
|
||||
content, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read restored %s after same-hash repair failure error = %v", name, readErr)
|
||||
}
|
||||
if string(content) != "old" {
|
||||
t.Errorf("restored %s after same-hash repair failure = %q, want %q", name, content, "old")
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
|
||||
t.Errorf("same-hash staging remains after successful rollback: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotePagesReleaseRepairsDanglingCurrent(t *testing.T) {
|
||||
pagesDir := t.TempDir()
|
||||
projectID := uint(23)
|
||||
releaseDir := pagesProjectReleaseDir(pagesDir, projectID, "new-hash")
|
||||
currentDir := pagesProjectCurrentDir(pagesDir, projectID)
|
||||
requireTestMkdirAll(t, filepath.Dir(currentDir))
|
||||
relTarget, err := filepath.Rel(filepath.Dir(currentDir), releaseDir)
|
||||
if err != nil {
|
||||
t.Fatalf("relative release target error = %v", err)
|
||||
}
|
||||
if err := os.Symlink(relTarget, currentDir); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
|
||||
requireTestMkdirAll(t, filepath.Dir(releaseDir))
|
||||
stagingDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(stagingDir, "index.html"), []byte("repaired"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write dangling repair staging error = %v", err)
|
||||
}
|
||||
project := pagesProjectRef{ProjectID: projectID, DeploymentID: 1, Checksum: "new-hash"}
|
||||
if err := writePagesMarker(stagingDir, project); err != nil {
|
||||
t.Fatalf("write dangling repair marker error = %v", err)
|
||||
}
|
||||
|
||||
if err := promotePagesRelease(stagingDir, releaseDir, project); err != nil {
|
||||
t.Fatalf("promotePagesRelease(dangling current) error = %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read repaired dangling current error = %v", err)
|
||||
}
|
||||
if string(content) != "repaired" {
|
||||
t.Errorf("repaired dangling current = %q, want %q", content, "repaired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchPagesCurrentDirCopiesOverLegacyDirectory(t *testing.T) {
|
||||
pagesDir := t.TempDir()
|
||||
currentDir := filepath.Join(pagesDir, "current")
|
||||
releaseDir := filepath.Join(pagesDir, "releases", "new")
|
||||
requireTestMkdirAll(t, currentDir)
|
||||
requireTestMkdirAll(t, releaseDir)
|
||||
if err := os.WriteFile(filepath.Join(currentDir, "index.html"), []byte("old"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write legacy current error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("new"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write new release error = %v", err)
|
||||
}
|
||||
|
||||
if err := switchPagesCurrentDir(currentDir, releaseDir, os.Rename); err != nil {
|
||||
t.Fatalf("switchPagesCurrentDir(legacy directory) error = %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read copied legacy current error = %v", err)
|
||||
}
|
||||
if string(content) != "new" {
|
||||
t.Errorf("copied legacy current = %q, want %q", content, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchPagesCurrentDirFallsBackWhenSymlinkUnavailable(t *testing.T) {
|
||||
pagesDir := t.TempDir()
|
||||
currentDir := filepath.Join(pagesDir, "current")
|
||||
releaseDir := filepath.Join(pagesDir, "releases", "new")
|
||||
requireTestMkdirAll(t, releaseDir)
|
||||
if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("new"), pagesFilePerm); err != nil {
|
||||
t.Fatalf("write fallback release error = %v", err)
|
||||
}
|
||||
symlinkErr := errors.New("injected symlink unavailable")
|
||||
if err := switchPagesCurrentDirWithOps(
|
||||
currentDir,
|
||||
releaseDir,
|
||||
os.Rename,
|
||||
func(string, string) error { return symlinkErr },
|
||||
); err != nil {
|
||||
t.Fatalf("switchPagesCurrentDirWithOps(symlink unavailable) error = %v", err)
|
||||
}
|
||||
info, err := os.Lstat(currentDir)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat copied current error = %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("copied current mode = %v, want directory", info.Mode())
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fallback current error = %v", err)
|
||||
}
|
||||
if string(content) != "new" {
|
||||
t.Errorf("fallback current = %q, want %q", content, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func requireTestMkdirAll(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, pagesDirPerm); err != nil {
|
||||
t.Fatalf("mkdir %q error = %v", dir, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
openrestyrender "Wavelet/openflare/share/render/openresty"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/nginx"
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
)
|
||||
|
||||
// Apply result constants indicate the outcome reported back to the server.
|
||||
const (
|
||||
ApplyResultSuccess = "success"
|
||||
ApplyResultWarning = "warning"
|
||||
ApplyResultFailed = "failed"
|
||||
)
|
||||
|
||||
// ConfigClient is the interface for communicating with the server control plane.
|
||||
type ConfigClient interface {
|
||||
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
|
||||
GetPagesDeploymentHash(ctx context.Context, deploymentID uint) (string, error)
|
||||
DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint, dst io.Writer, maxBytes int64) (int64, error)
|
||||
GetPagesProjectLatestHash(ctx context.Context, projectID uint) (*protocol.PagesProjectLatestHashResponse, error)
|
||||
DownloadPagesProjectLatestPackage(ctx context.Context, projectID uint, dst io.Writer, maxBytes int64) (int64, error)
|
||||
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
|
||||
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
|
||||
}
|
||||
|
||||
// NginxManager is the interface for managing the local OpenResty instance.
|
||||
type NginxManager interface {
|
||||
Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome
|
||||
EnsureRuntime(ctx context.Context, recreate bool) error
|
||||
EnsureSafeFallbackRuntime(ctx context.Context, reason string) error
|
||||
CurrentChecksum() (string, error)
|
||||
WAFIPGroupChecksums() (map[string]string, error)
|
||||
ReconcileWAFIPGroups(targetIDs []uint, changed []protocol.WAFIPGroup) error
|
||||
UpdateExistingWAFIPGroups(changed []protocol.WAFIPGroup) error
|
||||
EnsureWorkerReadAccess() error
|
||||
}
|
||||
|
||||
// Service orchestrates configuration synchronisation between the server and the local OpenResty instance.
|
||||
type Service struct {
|
||||
client ConfigClient
|
||||
nginxManager NginxManager
|
||||
stateStore *state.Store
|
||||
pagesDir string
|
||||
syncMu sync.Mutex
|
||||
}
|
||||
|
||||
// SetPagesDir sets the local directory used for pages deployment packages.
|
||||
func (s *Service) SetPagesDir(path string) {
|
||||
s.pagesDir = strings.TrimSpace(path)
|
||||
}
|
||||
|
||||
// New creates a new Service with the given client, nginx manager, and state store.
|
||||
func New(client ConfigClient, nginxManager NginxManager, stateStore *state.Store) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
nginxManager: nginxManager,
|
||||
stateStore: stateStore,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncOnce performs a single periodic sync against the given active config summary.
|
||||
func (s *Service) SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
return s.sync(ctx, false, target)
|
||||
}
|
||||
|
||||
// SyncOnStartup performs an initial sync at agent startup, applying config even when checksums already match.
|
||||
func (s *Service) SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
return s.sync(ctx, true, target)
|
||||
}
|
||||
|
||||
func (s *Service) sync(ctx context.Context, startup bool, target *protocol.ActiveConfigMeta) error {
|
||||
s.syncMu.Lock()
|
||||
defer s.syncMu.Unlock()
|
||||
|
||||
mode := syncMode(startup)
|
||||
snapshot, currentChecksum, err := s.loadSyncState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeSyncTarget(target)
|
||||
|
||||
if target == nil || target.Version == "" || target.Checksum == "" {
|
||||
return s.syncWithoutTarget(ctx, mode, startup, snapshot, currentChecksum)
|
||||
}
|
||||
if currentChecksum == target.Checksum {
|
||||
return s.syncMatchingChecksum(ctx, mode, startup, snapshot, currentChecksum, target)
|
||||
}
|
||||
return s.syncMismatchedChecksum(ctx, mode, startup, snapshot, currentChecksum, target)
|
||||
}
|
||||
|
||||
// ForceSyncOnce clears any blocked target state then unconditionally fetches and applies the active config.
|
||||
func (s *Service) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
s.syncMu.Lock()
|
||||
defer s.syncMu.Unlock()
|
||||
|
||||
snapshot, err := s.stateStore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasBlockedTarget(snapshot) {
|
||||
clearBlockedTarget(snapshot)
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
}
|
||||
currentChecksum, err := s.nginxManager.CurrentChecksum()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := s.client.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
slog.Error("fetch active config failed", "mode", "force", "error", err)
|
||||
return err
|
||||
}
|
||||
return s.applyIfNeeded(ctx, "force", true, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
|
||||
// WAFIPGroupChecksums returns the current per-group checksums held by the nginx manager.
|
||||
func (s *Service) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
if s.nginxManager == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return s.nginxManager.WAFIPGroupChecksums()
|
||||
}
|
||||
|
||||
// ApplyWAFIPGroups applies real-time changes only to groups already in the local authoritative snapshot.
|
||||
func (s *Service) ApplyWAFIPGroups(_ context.Context, groups []protocol.WAFIPGroup) error {
|
||||
if len(groups) == 0 || s.nginxManager == nil {
|
||||
return nil
|
||||
}
|
||||
return s.nginxManager.UpdateExistingWAFIPGroups(groups)
|
||||
}
|
||||
|
||||
func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta, config *protocol.ActiveConfigResponse) error {
|
||||
if currentChecksum == config.Checksum && !startup {
|
||||
return s.handleUpToDateConfig(ctx, mode, snapshot, config)
|
||||
}
|
||||
if target != nil && (target.Version != config.Version || target.Checksum != config.Checksum) {
|
||||
slog.Warn("active config changed between heartbeat and fetch", "heartbeat_version", target.Version, "heartbeat_checksum", target.Checksum, "fetched_version", config.Version, "fetched_checksum", config.Checksum)
|
||||
}
|
||||
if handled, err := s.handleBlockedConfigAfterFetch(ctx, mode, startup, snapshot, currentChecksum, config); handled {
|
||||
return err
|
||||
}
|
||||
if hasBlockedTarget(snapshot) {
|
||||
clearBlockedTarget(snapshot)
|
||||
}
|
||||
if snapshot.CurrentVersion == config.Version && snapshot.CurrentChecksum == config.Checksum && !startup {
|
||||
if err := s.syncPagesDeployments(ctx, snapshot, config); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
|
||||
// Successful reconcile must clear sticky LastError so health events can resolve.
|
||||
snapshot.LastError = ""
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
return s.applyRenderedConfig(ctx, mode, snapshot, currentChecksum, config)
|
||||
}
|
||||
|
||||
func (s *Service) applyRenderedConfig(ctx context.Context, mode string, snapshot *state.Snapshot, currentChecksum string, config *protocol.ActiveConfigResponse) error {
|
||||
rendered, err := renderActiveConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncPagesDeployments(ctx, snapshot, config); err != nil {
|
||||
return err
|
||||
}
|
||||
mainConfigChecksum := checksumString(rendered.mainConfig)
|
||||
routeConfigChecksum := checksumString(rendered.routeConfig)
|
||||
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
|
||||
alreadySynced := snapshotMatchesTarget(snapshot, config.Version, config.Checksum)
|
||||
outcome, message := normalizeApplyOutcome(s.nginxManager.Apply(ctx, rendered.mainConfig, rendered.routeConfig, rendered.supportFiles))
|
||||
applyResult := updateSnapshotFromApplyOutcome(mode, snapshot, config, outcome, message)
|
||||
|
||||
if err := s.stateStore.Save(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if !shouldReportApplyLog(alreadySynced, applyResult.reportResult) {
|
||||
slog.Debug("skipping duplicate apply log report", "version", config.Version, "checksum", config.Checksum, "result", applyResult.reportResult)
|
||||
if applyResult.reportResult == ApplyResultFailed {
|
||||
return outcomeError(config.Version, applyResult.message)
|
||||
}
|
||||
if err := s.syncReferencedWAFIPGroups(ctx, rendered.supportFiles); err != nil {
|
||||
slog.Error("sync referenced waf ip groups failed", "version", config.Version, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
|
||||
NodeID: snapshot.NodeID,
|
||||
Version: config.Version,
|
||||
Result: applyResult.reportResult,
|
||||
Message: applyResult.message,
|
||||
Checksum: config.Checksum,
|
||||
MainConfigChecksum: mainConfigChecksum,
|
||||
RouteConfigChecksum: routeConfigChecksum,
|
||||
SupportFileCount: len(rendered.supportFiles),
|
||||
}); err != nil {
|
||||
slog.Error("report apply log failed", "version", config.Version, "result", applyResult.reportResult, "error", err)
|
||||
return err
|
||||
}
|
||||
if applyResult.reportResult == ApplyResultFailed {
|
||||
slog.Warn("failed apply log reported", "version", config.Version)
|
||||
return outcomeError(config.Version, applyResult.message)
|
||||
}
|
||||
if err := s.syncReferencedWAFIPGroups(ctx, rendered.supportFiles); err != nil {
|
||||
slog.Error("sync referenced waf ip groups failed", "version", config.Version, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Debug("apply log reported", "version", config.Version, "result", applyResult.reportResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncReferencedWAFIPGroups(ctx context.Context, supportFiles []protocol.SupportFile) error {
|
||||
ids, err := referencedWAFIPGroupIDs(supportFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
if s.nginxManager == nil {
|
||||
return nil
|
||||
}
|
||||
return s.nginxManager.ReconcileWAFIPGroups([]uint{}, nil)
|
||||
}
|
||||
checksums, err := s.WAFIPGroupChecksums()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetChecksums := make(map[string]string, len(ids))
|
||||
for _, id := range ids {
|
||||
key := strconv.FormatUint(uint64(id), 10)
|
||||
if value := strings.TrimSpace(checksums[key]); value != "" {
|
||||
targetChecksums[key] = value
|
||||
}
|
||||
}
|
||||
response, err := s.client.SyncWAFIPGroups(ctx, protocol.WAFIPGroupSyncRequest{
|
||||
IDs: ids,
|
||||
Checksums: targetChecksums,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.nginxManager == nil {
|
||||
return nil
|
||||
}
|
||||
var changed []protocol.WAFIPGroup
|
||||
if response != nil {
|
||||
changed = response.Groups
|
||||
}
|
||||
return s.nginxManager.ReconcileWAFIPGroups(ids, changed)
|
||||
}
|
||||
|
||||
type renderedActiveConfig struct {
|
||||
mainConfig string
|
||||
routeConfig string
|
||||
supportFiles []protocol.SupportFile
|
||||
}
|
||||
|
||||
func renderActiveConfig(config *protocol.ActiveConfigResponse) (*renderedActiveConfig, error) {
|
||||
if config == nil {
|
||||
return nil, errors.New("active config is nil")
|
||||
}
|
||||
sourceJSON := strings.TrimSpace(config.SourceConfigJSON)
|
||||
if sourceJSON == "" {
|
||||
return nil, errors.New("active config source_config_json is empty")
|
||||
}
|
||||
rendered, err := openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(config.SupportFiles))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := fromOpenRestySupportFiles(rendered.SupportFiles)
|
||||
files = append(files, protocol.SupportFile{Path: openrestyrender.SourceConfigFileName, Content: sourceJSON})
|
||||
return &renderedActiveConfig{
|
||||
mainConfig: rendered.MainConfig,
|
||||
routeConfig: rendered.RouteConfig,
|
||||
supportFiles: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toOpenRestySupportFiles(files []protocol.SupportFile) []openrestyrender.SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]openrestyrender.SupportFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
result = append(result, openrestyrender.SupportFile{Path: file.Path, Content: file.Content})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []protocol.SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]protocol.SupportFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
result = append(result, protocol.SupportFile{Path: file.Path, Content: file.Content})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) ([]uint, error) {
|
||||
var content string
|
||||
for _, file := range supportFiles {
|
||||
if file.Path == "waf_config.json" {
|
||||
content = strings.TrimSpace(file.Content)
|
||||
break
|
||||
}
|
||||
}
|
||||
if content == "" {
|
||||
return []uint{}, nil
|
||||
}
|
||||
var payload openrestyrender.WAFDocument
|
||||
if err := json.Unmarshal([]byte(content), &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode waf_config.json for ip group references: %w", err)
|
||||
}
|
||||
seen := make(map[uint]struct{})
|
||||
for _, group := range payload.RuleGroups {
|
||||
for _, legacyIDs := range [][]uint{group.IPWhitelistGroups, group.IPBlacklistGroups} {
|
||||
for _, id := range legacyIDs {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for nodeID, node := range group.Graph.Nodes {
|
||||
if node.Type != "ip_match" {
|
||||
continue
|
||||
}
|
||||
var config *struct {
|
||||
IPGroupIDs []uint `json:"ip_group_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil || config == nil {
|
||||
if err == nil {
|
||||
err = errors.New("config must be a JSON object")
|
||||
}
|
||||
return nil, fmt.Errorf("decode ip_match config for rule group %d node %s: %w", group.ID, nodeID, err)
|
||||
}
|
||||
for _, id := range config.IPGroupIDs {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(seen))
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func shouldReportNoopApply(snapshot *state.Snapshot, version string, checksum string) bool {
|
||||
if snapshot == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(snapshot.CurrentVersion) != strings.TrimSpace(version) ||
|
||||
strings.TrimSpace(snapshot.CurrentChecksum) != strings.TrimSpace(checksum)
|
||||
}
|
||||
|
||||
func (s *Service) reportNoopApply(ctx context.Context, nodeID string, version string, checksum string, mainConfigChecksum string, routeConfigChecksum string, supportFileCount int) error {
|
||||
message := "local config already matches active version; apply skipped"
|
||||
if err := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
|
||||
NodeID: nodeID,
|
||||
Version: strings.TrimSpace(version),
|
||||
Result: ApplyResultSuccess,
|
||||
Message: message,
|
||||
Checksum: strings.TrimSpace(checksum),
|
||||
MainConfigChecksum: strings.TrimSpace(mainConfigChecksum),
|
||||
RouteConfigChecksum: strings.TrimSpace(routeConfigChecksum),
|
||||
SupportFileCount: supportFileCount,
|
||||
}); err != nil {
|
||||
slog.Error("report noop apply log failed", "version", version, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Debug("noop apply log reported", "version", version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func outcomeError(version string, message string) error {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if trimmed == "" {
|
||||
trimmed = "openresty apply failed"
|
||||
}
|
||||
return fmt.Errorf("apply version %s failed: %s", version, trimmed)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRuntimeForCurrentConfig(ctx context.Context, mode string, snapshot *state.Snapshot, currentChecksum string) error {
|
||||
if strings.TrimSpace(currentChecksum) == "" {
|
||||
slog.Warn("blocked config cannot be retried and no local checksum is available for runtime recovery", "mode", mode, "blocked_version", snapshot.BlockedVersion)
|
||||
reason := fmt.Sprintf("blocked config %s has no valid local config available for runtime recovery", strings.TrimSpace(snapshot.BlockedVersion))
|
||||
if err := s.nginxManager.EnsureSafeFallbackRuntime(ctx, reason); err != nil {
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
return err
|
||||
}
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = "safe default fallback runtime started"
|
||||
return nil
|
||||
}
|
||||
slog.Info("ensuring runtime with current local config while active target remains blocked", "mode", mode, "current_version", snapshot.CurrentVersion, "current_checksum", currentChecksum, "blocked_version", snapshot.BlockedVersion)
|
||||
if err := s.nginxManager.EnsureRuntime(ctx, true); err != nil {
|
||||
if strings.TrimSpace(snapshot.CurrentChecksum) == "" {
|
||||
reason := fmt.Sprintf("blocked config %s has no historical config and current local config cannot start: %v", strings.TrimSpace(snapshot.BlockedVersion), err)
|
||||
fallbackErr := s.nginxManager.EnsureSafeFallbackRuntime(ctx, reason)
|
||||
if fallbackErr == nil {
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = "safe default fallback runtime started"
|
||||
return nil
|
||||
}
|
||||
err = fmt.Errorf("%w; fallback recovery failed: %w", err, fallbackErr)
|
||||
}
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
return err
|
||||
}
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
if strings.TrimSpace(snapshot.OpenrestyMessage) == strings.TrimSpace(snapshot.BlockedReason) {
|
||||
snapshot.OpenrestyMessage = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markBlockedTarget(snapshot *state.Snapshot, version string, checksum string, reason string) {
|
||||
if snapshot == nil {
|
||||
return
|
||||
}
|
||||
snapshot.BlockedVersion = strings.TrimSpace(version)
|
||||
snapshot.BlockedChecksum = strings.TrimSpace(checksum)
|
||||
snapshot.BlockedReason = strings.TrimSpace(reason)
|
||||
}
|
||||
|
||||
func clearBlockedTarget(snapshot *state.Snapshot) {
|
||||
if snapshot == nil {
|
||||
return
|
||||
}
|
||||
snapshot.BlockedVersion = ""
|
||||
snapshot.BlockedChecksum = ""
|
||||
snapshot.BlockedReason = ""
|
||||
}
|
||||
|
||||
func hasBlockedTarget(snapshot *state.Snapshot) bool {
|
||||
return snapshot != nil && (strings.TrimSpace(snapshot.BlockedVersion) != "" || strings.TrimSpace(snapshot.BlockedChecksum) != "")
|
||||
}
|
||||
|
||||
func isBlockedTarget(snapshot *state.Snapshot, version string, checksum string) bool {
|
||||
if snapshot == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(snapshot.BlockedVersion) == strings.TrimSpace(version) &&
|
||||
strings.TrimSpace(snapshot.BlockedChecksum) == strings.TrimSpace(checksum) &&
|
||||
(strings.TrimSpace(version) != "" || strings.TrimSpace(checksum) != "")
|
||||
}
|
||||
|
||||
func checksumString(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/nginx"
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
"Wavelet/openflare/plugins/agent/state"
|
||||
)
|
||||
|
||||
func syncMode(startup bool) string {
|
||||
if startup {
|
||||
return "startup"
|
||||
}
|
||||
return "periodic"
|
||||
}
|
||||
|
||||
func normalizeSyncTarget(target *protocol.ActiveConfigMeta) {
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
target.Version = strings.TrimSpace(target.Version)
|
||||
target.Checksum = strings.TrimSpace(target.Checksum)
|
||||
}
|
||||
|
||||
func (s *Service) loadSyncState() (*state.Snapshot, string, error) {
|
||||
snapshot, err := s.stateStore.Load()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
currentChecksum, err := s.nginxManager.CurrentChecksum()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return snapshot, currentChecksum, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncWithoutTarget(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string) error {
|
||||
if !startup {
|
||||
slog.Debug("skipping sync because heartbeat returned no active config summary", "mode", mode)
|
||||
return nil
|
||||
}
|
||||
slog.Debug("sync startup fallback: active config summary unavailable, fetching active config directly")
|
||||
config, fetchErr := s.client.GetActiveConfig(ctx)
|
||||
if fetchErr != nil {
|
||||
slog.Error("fetch active config failed", "mode", mode, "error", fetchErr)
|
||||
return fetchErr
|
||||
}
|
||||
target := &protocol.ActiveConfigMeta{
|
||||
Version: config.Version,
|
||||
Checksum: config.Checksum,
|
||||
}
|
||||
return s.applyIfNeeded(ctx, mode, startup, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
|
||||
func (s *Service) syncMatchingChecksum(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta) error {
|
||||
if startup {
|
||||
config, fetchErr := s.client.GetActiveConfig(ctx)
|
||||
if fetchErr != nil {
|
||||
slog.Error("fetch active config failed", "mode", mode, "error", fetchErr)
|
||||
return fetchErr
|
||||
}
|
||||
return s.applyIfNeeded(ctx, mode, startup, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
return s.finishUpToDateSync(ctx, mode, snapshot, target)
|
||||
}
|
||||
|
||||
func (s *Service) finishUpToDateSync(ctx context.Context, mode string, snapshot *state.Snapshot, target *protocol.ActiveConfigMeta) error {
|
||||
if err := s.reconcilePages(ctx, mode, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", target.Version)
|
||||
if shouldReportNoopApply(snapshot, target.Version, target.Checksum) {
|
||||
if err := s.reportNoopApply(ctx, snapshot.NodeID, target.Version, target.Checksum, "", "", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
snapshot.CurrentVersion = target.Version
|
||||
snapshot.CurrentChecksum = target.Checksum
|
||||
clearBlockedTarget(snapshot)
|
||||
snapshot.LastError = ""
|
||||
slog.Debug("sync finished without changes", "mode", mode, "version", target.Version)
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
|
||||
func (s *Service) reconcilePages(ctx context.Context, mode string, snapshot *state.Snapshot) error {
|
||||
if !pagesReconcileNeeded(snapshot) {
|
||||
return nil
|
||||
}
|
||||
if pagesDiscoveryNeeded(snapshot) {
|
||||
config, err := s.client.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
slog.Error("fetch active config failed", "mode", mode, "error", err)
|
||||
return err
|
||||
}
|
||||
return s.syncPagesDeployments(ctx, snapshot, config)
|
||||
}
|
||||
return s.syncPagesDeployments(ctx, snapshot, nil)
|
||||
}
|
||||
|
||||
func (s *Service) syncMismatchedChecksum(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta) error {
|
||||
if isBlockedTarget(snapshot, target.Version, target.Checksum) {
|
||||
slog.Warn("skipping blocked config version after previous failed apply", "mode", mode, "version", target.Version, "checksum", target.Checksum)
|
||||
if startup {
|
||||
if err := s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hasBlockedTarget(snapshot) {
|
||||
clearBlockedTarget(snapshot)
|
||||
}
|
||||
if snapshot.CurrentVersion == target.Version && snapshot.CurrentChecksum == target.Checksum && !startup {
|
||||
reconciled := pagesReconcileNeeded(snapshot)
|
||||
if err := s.reconcilePages(ctx, mode, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if !reconciled {
|
||||
slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum)
|
||||
}
|
||||
// Successful reconcile must clear sticky LastError so health events can resolve.
|
||||
snapshot.LastError = ""
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
|
||||
config, err := s.client.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
slog.Error("fetch active config failed", "mode", mode, "error", err)
|
||||
return err
|
||||
}
|
||||
return s.applyIfNeeded(ctx, mode, startup, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
|
||||
func (s *Service) handleUpToDateConfig(ctx context.Context, mode string, snapshot *state.Snapshot, config *protocol.ActiveConfigResponse) error {
|
||||
if err := s.syncPagesDeployments(ctx, snapshot, config); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", config.Version)
|
||||
if shouldReportNoopApply(snapshot, config.Version, config.Checksum) {
|
||||
rendered, renderErr := renderActiveConfig(config)
|
||||
if renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
if err := s.reportNoopApply(
|
||||
ctx,
|
||||
snapshot.NodeID,
|
||||
config.Version,
|
||||
config.Checksum,
|
||||
checksumString(rendered.mainConfig),
|
||||
checksumString(rendered.routeConfig),
|
||||
len(rendered.supportFiles),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
snapshot.CurrentVersion = config.Version
|
||||
snapshot.CurrentChecksum = config.Checksum
|
||||
clearBlockedTarget(snapshot)
|
||||
snapshot.LastError = ""
|
||||
slog.Debug("sync finished without changes", "mode", mode, "version", config.Version)
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
|
||||
func (s *Service) handleBlockedConfigAfterFetch(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, config *protocol.ActiveConfigResponse) (bool, error) {
|
||||
if !isBlockedTarget(snapshot, config.Version, config.Checksum) {
|
||||
return false, nil
|
||||
}
|
||||
slog.Warn("skipping blocked config after fetch because the same version previously failed", "mode", mode, "version", config.Version, "checksum", config.Checksum)
|
||||
if startup {
|
||||
if err := s.ensureRuntimeForCurrentConfig(ctx, mode, snapshot, currentChecksum); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, s.stateStore.Save(snapshot)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type applyOutcomeResult struct {
|
||||
reportResult string
|
||||
message string
|
||||
}
|
||||
|
||||
func normalizeApplyOutcome(outcome nginx.ApplyOutcome) (nginx.ApplyOutcome, string) {
|
||||
message := strings.TrimSpace(outcome.Message)
|
||||
if outcome.Status == "" {
|
||||
outcome.Status = nginx.ApplyStatusFatal
|
||||
if message == "" {
|
||||
message = "openresty apply returned empty outcome"
|
||||
}
|
||||
}
|
||||
return outcome, message
|
||||
}
|
||||
|
||||
func updateSnapshotFromApplyOutcome(mode string, snapshot *state.Snapshot, config *protocol.ActiveConfigResponse, outcome nginx.ApplyOutcome, message string) applyOutcomeResult {
|
||||
result := applyOutcomeResult{reportResult: ApplyResultFailed, message: message}
|
||||
switch outcome.Status {
|
||||
case nginx.ApplyStatusSuccess:
|
||||
slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version)
|
||||
snapshot.CurrentVersion = config.Version
|
||||
snapshot.CurrentChecksum = config.Checksum
|
||||
clearBlockedTarget(snapshot)
|
||||
snapshot.LastError = ""
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = ""
|
||||
result.reportResult = ApplyResultSuccess
|
||||
if result.message == "" {
|
||||
result.message = "apply success"
|
||||
}
|
||||
case nginx.ApplyStatusWarning:
|
||||
if result.message == "" {
|
||||
result.message = "apply rolled back to previous config"
|
||||
}
|
||||
slog.Warn("openresty config apply rolled back", "mode", mode, "version", config.Version, "message", result.message)
|
||||
markBlockedTarget(snapshot, config.Version, config.Checksum, result.message)
|
||||
snapshot.LastError = result.message
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = result.message
|
||||
result.reportResult = ApplyResultWarning
|
||||
case nginx.ApplyStatusFatal:
|
||||
// 致命错误与普通失败同走失败路径:标记阻塞并上报 Unhealthy。
|
||||
fallthrough
|
||||
default:
|
||||
if result.message == "" {
|
||||
result.message = "openresty apply failed"
|
||||
}
|
||||
slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "message", result.message)
|
||||
markBlockedTarget(snapshot, config.Version, config.Checksum, result.message)
|
||||
snapshot.LastError = result.message
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = result.message
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func snapshotMatchesTarget(snapshot *state.Snapshot, version string, checksum string) bool {
|
||||
if snapshot == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(snapshot.CurrentVersion) == strings.TrimSpace(version) &&
|
||||
strings.TrimSpace(snapshot.CurrentChecksum) == strings.TrimSpace(checksum)
|
||||
}
|
||||
|
||||
func shouldReportApplyLog(alreadySynced bool, result string) bool {
|
||||
if result != ApplyResultSuccess {
|
||||
return true
|
||||
}
|
||||
return !alreadySynced
|
||||
}
|
||||
Reference in New Issue
Block a user