mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
699e95f12c
Result: {"status":"keep","total_issues":56,"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47}
992 lines
30 KiB
Go
992 lines
30 KiB
Go
// 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"
|
|
|
|
"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 (
|
|
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)
|
|
}
|