Pulse/internal/updates/manager.go
rcourtman 41e075b9ec fix(updates): Add RSS/Atom feed fallback for GitHub rate limits
When the GitHub API returns 403 (rate limited), Pulse now falls back
to parsing the releases.atom feed which doesn't count against API
rate limits. This ensures users can still check for updates even
when rate limited.

The feed parser:
- Extracts version tags from Atom feed entries
- Filters prereleases for stable channel users
- Returns the first matching release

Fixes #840
2025-12-20 10:54:14 +00:00

1668 lines
47 KiB
Go

package updates
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
// UpdateStatus represents the current status of an update
type UpdateStatus struct {
Status string `json:"status"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
UpdatedAt string `json:"updatedAt"`
}
// ReleaseInfo represents a GitHub release
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
// UpdateInfo represents available update information
type UpdateInfo struct {
Available bool `json:"available"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseNotes string `json:"releaseNotes"`
ReleaseDate time.Time `json:"releaseDate"`
DownloadURL string `json:"downloadUrl"`
IsPrerelease bool `json:"isPrerelease"`
Warning string `json:"warning,omitempty"`
}
var (
errGitHubRateLimited = errors.New("GitHub API rate limit exceeded")
stageDelayOnce sync.Once
stageDelayValue time.Duration
)
// Manager handles update operations
type Manager struct {
config *config.Config
history *UpdateHistory
status UpdateStatus
statusMu sync.RWMutex
checkCache map[string]*UpdateInfo // keyed by channel
cacheTime map[string]time.Time // keyed by channel
cacheDuration time.Duration
progressChan chan UpdateStatus
queue *UpdateQueue
sseBroadcast *SSEBroadcaster
}
// ApplyUpdateRequest describes an update request initiated via the API/UI.
type ApplyUpdateRequest struct {
DownloadURL string
Channel string
InitiatedBy InitiatedBy
InitiatedVia InitiatedVia
Notes string
}
// NewManager creates a new update manager
func NewManager(cfg *config.Config) *Manager {
m := &Manager{
config: cfg,
checkCache: make(map[string]*UpdateInfo),
cacheTime: make(map[string]time.Time),
cacheDuration: 5 * time.Minute, // Cache update checks for 5 minutes
progressChan: make(chan UpdateStatus, 100),
queue: NewUpdateQueue(),
sseBroadcast: NewSSEBroadcaster(),
status: UpdateStatus{
Status: "idle",
UpdatedAt: time.Now().Format(time.RFC3339),
},
}
// Clean up old temp directories from previous failed/killed updates
go m.cleanupOldTempDirs()
// Start heartbeat for SSE connections (every 30 seconds)
go m.sseHeartbeatLoop()
return m
}
// SetHistory wires an update history sink for recording update progress.
func (m *Manager) SetHistory(history *UpdateHistory) {
m.history = history
}
// GetProgressChannel returns the channel for update progress
func (m *Manager) GetProgressChannel() <-chan UpdateStatus {
return m.progressChan
}
// Close closes the progress channel and cleans up resources
func (m *Manager) Close() {
close(m.progressChan)
if m.sseBroadcast != nil {
m.sseBroadcast.Close()
}
}
// GetSSEBroadcaster returns the SSE broadcaster
func (m *Manager) GetSSEBroadcaster() *SSEBroadcaster {
return m.sseBroadcast
}
// GetQueue returns the update queue
func (m *Manager) GetQueue() *UpdateQueue {
return m.queue
}
// CheckForUpdates checks GitHub for available updates using saved config channel
func (m *Manager) CheckForUpdates(ctx context.Context) (*UpdateInfo, error) {
return m.CheckForUpdatesWithChannel(ctx, "")
}
// CheckForUpdatesWithChannel checks GitHub for available updates with optional channel override
func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string) (*UpdateInfo, error) {
// Get current version first to auto-detect channel if needed
currentInfo, err := GetCurrentVersion()
if err != nil {
m.updateStatus("error", 0, "Failed to get current version")
return nil, fmt.Errorf("failed to get current version: %w", err)
}
// Track whether an explicit channel override was provided
explicitChannelProvided := channel != ""
// Use provided channel, or fall back to config, or auto-detect from current version
if channel == "" {
channel = m.config.UpdateChannel
}
if channel == "" && currentInfo.Channel != "" {
// Auto-detect channel from current version (RC users get RC updates)
channel = currentInfo.Channel
}
if channel == "" {
channel = "stable"
}
// Don't use cache when channel is explicitly provided (UI might have changed it)
// But DO use cache for auto-detected or default channels
useCache := !explicitChannelProvided
// Check cache first (only if using saved channel)
if useCache {
m.statusMu.RLock()
cachedInfo, hasCached := m.checkCache[channel]
cachedTime, hasTime := m.cacheTime[channel]
if hasCached && hasTime && time.Since(cachedTime) < m.cacheDuration {
m.statusMu.RUnlock()
return cachedInfo, nil
}
m.statusMu.RUnlock()
}
m.updateStatus("checking", 0, "Checking for updates...")
// Skip update check for Docker
if currentInfo.IsDocker {
info := &UpdateInfo{
Available: false,
CurrentVersion: currentInfo.Version,
LatestVersion: currentInfo.Version,
}
if useCache {
m.statusMu.Lock()
m.checkCache[channel] = info
m.cacheTime[channel] = time.Now()
m.statusMu.Unlock()
}
m.updateStatus("idle", 0, "Updates not available in Docker")
return info, nil
}
// Skip update check for source builds
if currentInfo.IsSourceBuild {
info := &UpdateInfo{
Available: false,
CurrentVersion: currentInfo.Version,
LatestVersion: currentInfo.Version,
}
if useCache {
m.statusMu.Lock()
m.checkCache[channel] = info
m.cacheTime[channel] = time.Now()
m.statusMu.Unlock()
}
m.updateStatus("idle", 0, "Updates not available for source builds")
return info, nil
}
// Parse current version first
currentVer, err := ParseVersion(currentInfo.Version)
if err != nil {
m.updateStatus("error", 0, "Invalid current version")
return nil, fmt.Errorf("failed to parse current version: %w", err)
}
// Get latest release from GitHub with specified channel and current version
release, err := m.getLatestReleaseForChannel(ctx, channel, currentVer)
if err != nil {
if errors.Is(err, errGitHubRateLimited) {
log.Warn().Err(err).Str("channel", channel).Msg("GitHub rate limit encountered while checking for updates")
if useCache {
m.statusMu.RLock()
cachedInfo, hasCached := m.checkCache[channel]
m.statusMu.RUnlock()
if hasCached && cachedInfo != nil {
m.updateStatus("idle", 0, "Using cached update info (GitHub rate limit)")
return cachedInfo, nil
}
}
info := &UpdateInfo{
Available: false,
CurrentVersion: currentInfo.Version,
LatestVersion: currentInfo.Version,
DownloadURL: "",
IsPrerelease: currentVer.IsPrerelease(),
Warning: "Update check temporarily unavailable because GitHub rate limit was reached. Try again in a few minutes.",
}
m.updateStatus("idle", 0, "GitHub rate limit reached during update check")
return info, nil
}
// Check if this is a "no releases found" error - handle gracefully
if strings.Contains(err.Error(), "no releases found") {
// No releases available for this channel - return "no update available"
info := &UpdateInfo{
Available: false,
CurrentVersion: currentInfo.Version,
LatestVersion: currentInfo.Version,
}
if useCache {
m.statusMu.Lock()
m.checkCache[channel] = info
m.cacheTime[channel] = time.Now()
m.statusMu.Unlock()
}
m.updateStatus("idle", 0, fmt.Sprintf("No releases available for %s channel", channel))
return info, nil
}
// For other errors, return the error
m.updateStatus("error", 0, "Failed to check for updates", err)
return nil, err
}
latestVer, err := ParseVersion(release.TagName)
if err != nil {
parseErr := fmt.Errorf("failed to parse latest version: %w", err)
m.updateStatus("error", 0, "Invalid latest version", parseErr)
return nil, parseErr
}
// Find download URL for current architecture
downloadURL := ""
arch := runtime.GOARCH
// Map Go architecture names to release asset names
archMap := map[string]string{
"amd64": "amd64",
"arm64": "arm64",
"arm": "armv7",
"386": "386",
}
targetArch, ok := archMap[arch]
if !ok {
targetArch = arch // Use as-is if not in map
}
// Look for architecture-specific binary
targetName := fmt.Sprintf("pulse-%s-linux-%s.tar.gz", release.TagName, targetArch)
for _, asset := range release.Assets {
if asset.Name == targetName {
downloadURL = asset.BrowserDownloadURL
break
}
}
// Fallback to any pulse tarball if exact match not found
if downloadURL == "" {
for _, asset := range release.Assets {
if strings.HasPrefix(asset.Name, "pulse-") &&
strings.Contains(asset.Name, "linux") &&
strings.HasSuffix(asset.Name, ".tar.gz") {
downloadURL = asset.BrowserDownloadURL
break
}
}
}
info := &UpdateInfo{
Available: latestVer.IsNewerThan(currentVer),
CurrentVersion: currentInfo.Version,
LatestVersion: release.TagName,
ReleaseNotes: release.Body,
ReleaseDate: release.PublishedAt,
DownloadURL: downloadURL,
IsPrerelease: release.Prerelease,
}
// Cache the result (only if using saved channel)
if useCache {
m.statusMu.Lock()
m.checkCache[channel] = info
m.cacheTime[channel] = time.Now()
m.statusMu.Unlock()
}
status := "idle"
message := "No updates available"
if info.Available {
status = "available"
message = fmt.Sprintf("Update available: %s", info.LatestVersion)
}
m.updateStatus(status, 100, message)
return info, nil
}
// ApplyUpdate downloads and applies an update
func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error {
// Validate download URL (allow test server URLs when PULSE_UPDATE_SERVER is set)
if req.DownloadURL == "" {
return fmt.Errorf("download URL is required")
}
if os.Getenv("PULSE_UPDATE_SERVER") == "" {
if !strings.HasPrefix(req.DownloadURL, "https://github.com/rcourtman/Pulse/releases/download/") {
return fmt.Errorf("invalid download URL")
}
}
// Check if Docker
currentInfo, _ := GetCurrentVersion()
if currentInfo.IsDocker {
return fmt.Errorf("updates cannot be applied in Docker environment")
}
// Check for pre-v4 installation
if isPreV4Installation() {
return fmt.Errorf("manual migration required: Pulse v4 is a complete rewrite. Please create a fresh installation. See https://github.com/rcourtman/Pulse/releases/v4.0.0")
}
// Enqueue the update job
job, accepted := m.queue.Enqueue(req.DownloadURL)
if !accepted {
return fmt.Errorf("update already in progress")
}
// Mark job as running
m.queue.MarkRunning(job.ID)
// Use job context instead of passed context
ctx = job.Context
m.updateStatus("downloading", 10, "Downloading update...")
channel := m.resolveChannel(req.Channel, currentInfo)
targetVersion := inferVersionFromDownloadURL(req.DownloadURL)
initiatedBy := req.InitiatedBy
if initiatedBy == "" {
initiatedBy = InitiatedByUser
}
initiatedVia := req.InitiatedVia
if initiatedVia == "" {
initiatedVia = InitiatedViaAPI
}
start := time.Now()
eventID := m.createHistoryEntry(ctx, UpdateHistoryEntry{
Action: ActionUpdate,
Channel: channel,
VersionFrom: currentInfo.Version,
VersionTo: targetVersion,
DeploymentType: currentInfo.DeploymentType,
InitiatedBy: initiatedBy,
InitiatedVia: initiatedVia,
Status: StatusInProgress,
Notes: req.Notes,
})
var runErr error
defer func() {
if eventID == "" {
return
}
status := StatusSuccess
if runErr != nil {
status = StatusFailed
}
m.completeHistoryEntry(ctx, eventID, status, start, runErr)
}()
// Create temp directory in a location we can write to
// Try multiple locations in order of preference
var tempDir string
var err error
// Try data directory first
dataDir := os.Getenv("PULSE_DATA_DIR")
if dataDir == "" {
dataDir = "/etc/pulse"
}
// Try to create temp dir in data directory
tempDir, err = os.MkdirTemp(dataDir, "pulse-update-*")
if err != nil {
// Fallback to /tmp
tempDir, err = os.MkdirTemp("/tmp", "pulse-update-*")
if err != nil {
// Last resort: current directory
tempDir, err = os.MkdirTemp(".", "pulse-update-*")
if err != nil {
tempErr := fmt.Errorf("failed to create temp directory in any location: %w", err)
m.updateStatus("error", 10, "Failed to create temp directory", tempErr)
runErr = tempErr
m.queue.MarkCompleted(job.ID, tempErr)
return tempErr
}
}
}
defer os.RemoveAll(tempDir)
// Download update
tarballPath := filepath.Join(tempDir, "update.tar.gz")
downloadBytes, err := m.downloadFile(ctx, req.DownloadURL, tarballPath)
if err != nil {
downloadErr := fmt.Errorf("failed to download update: %w", err)
m.updateStatus("error", 20, "Failed to download update", downloadErr)
m.queue.MarkCompleted(job.ID, downloadErr)
runErr = downloadErr
return runErr
}
if downloadBytes > 0 {
m.updateHistoryEntry(ctx, eventID, func(entry *UpdateHistoryEntry) {
entry.DownloadBytes = downloadBytes
})
}
// Verify checksum if available
m.updateStatus("verifying", 30, "Verifying download...")
if err := m.verifyChecksum(ctx, req.DownloadURL, tarballPath); err != nil {
checksumErr := fmt.Errorf("checksum verification failed: %w", err)
m.updateStatus("error", 30, "Failed to verify update checksum", checksumErr)
m.queue.MarkCompleted(job.ID, checksumErr)
runErr = checksumErr
return runErr
}
log.Info().Msg("Checksum verification passed")
m.updateStatus("extracting", 40, "Extracting update...")
// Extract tarball
extractDir := filepath.Join(tempDir, "extracted")
if err := m.extractTarball(tarballPath, extractDir); err != nil {
extractErr := fmt.Errorf("failed to extract update: %w", err)
m.updateStatus("error", 40, "Failed to extract update", extractErr)
m.queue.MarkCompleted(job.ID, extractErr)
runErr = extractErr
return runErr
}
m.updateStatus("backing-up", 60, "Creating backup...")
// Create backup
backupPath, err := m.createBackup()
if err != nil {
backupErr := fmt.Errorf("failed to create backup: %w", err)
m.updateStatus("error", 60, "Failed to create backup", backupErr)
m.queue.MarkCompleted(job.ID, backupErr)
runErr = backupErr
return runErr
}
log.Info().Str("backup", backupPath).Msg("Created backup")
m.updateHistoryEntry(ctx, eventID, func(entry *UpdateHistoryEntry) {
entry.BackupPath = backupPath
})
m.updateStatus("applying", 80, "Applying update...")
// Apply the update files
// With the new directory structure (/opt/pulse/bin/), the pulse user has write access
log.Info().Msg("Applying update files")
if err := m.applyUpdateFiles(extractDir); err != nil {
applyErr := fmt.Errorf("failed to apply update: %w", err)
m.updateStatus("error", 80, "Failed to apply update", applyErr)
m.queue.MarkCompleted(job.ID, applyErr)
runErr = applyErr
// Attempt to restore backup
if restoreErr := m.restoreBackup(backupPath); restoreErr != nil {
log.Error().Err(restoreErr).Msg("Failed to restore backup")
}
return runErr
}
m.updateStatus("restarting", 95, "Restarting service...")
// Mark job as completed
m.queue.MarkCompleted(job.ID, nil)
// Schedule a clean exit after a short delay - systemd will restart us
if !dockerUpdatesAllowed() {
go func() {
time.Sleep(2 * time.Second)
log.Info().Msg("Exiting for restart after update")
os.Exit(0)
}()
} else {
log.Info().Msg("Skipping process exit after update (mock/CI mode)")
}
m.updateStatus("completed", 100, "Update completed, restarting...")
return nil
}
// GetStatus returns the current update status
func (m *Manager) GetStatus() UpdateStatus {
m.statusMu.RLock()
defer m.statusMu.RUnlock()
return m.status
}
// GetCachedUpdateInfo returns the cached update info without making a network request
// Returns nil if no cached info is available
// Uses the configured or auto-detected channel
func (m *Manager) GetCachedUpdateInfo() *UpdateInfo {
// Determine which channel to use (same logic as CheckForUpdates)
channel := m.config.UpdateChannel
if channel == "" {
// Try to auto-detect from current version
currentInfo, err := GetCurrentVersion()
if err == nil && currentInfo.Channel != "" {
channel = currentInfo.Channel
}
}
if channel == "" {
channel = "stable"
}
m.statusMu.RLock()
defer m.statusMu.RUnlock()
return m.checkCache[channel]
}
// getLatestReleaseForChannel fetches the latest release from GitHub for a specific channel
func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string, currentVer *Version) (*ReleaseInfo, error) {
if channel == "" {
channel = "stable"
}
log.Info().
Str("channel", channel).
Str("currentVersion", currentVer.String()).
Bool("isPrerelease", currentVer.IsPrerelease()).
Msg("Checking for updates")
// GitHub API URL (can be overridden for testing)
// Always fetch all releases so we can do version-aware filtering
baseURL := os.Getenv("PULSE_UPDATE_SERVER")
if baseURL == "" {
baseURL = "https://api.github.com"
}
url := baseURL + "/repos/rcourtman/Pulse/releases"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
// Add headers
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "Pulse-Update-Checker")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch releases: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
log.Warn().
Str("channel", channel).
Str("rateLimitRemaining", resp.Header.Get("X-RateLimit-Remaining")).
Str("rateLimitReset", resp.Header.Get("X-RateLimit-Reset")).
Msg("GitHub API rate limit encountered, trying RSS fallback")
// Try RSS/Atom feed as fallback - doesn't count against rate limits
if feedRelease, err := m.getLatestReleaseFromFeed(ctx, channel); err == nil {
log.Info().Str("version", feedRelease.TagName).Msg("Got release info from RSS feed fallback")
return feedRelease, nil
}
detail := strings.TrimSpace(string(body))
if detail == "" {
detail = resp.Status
}
return nil, fmt.Errorf("%w: %s", errGitHubRateLimited, detail)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
detail := strings.TrimSpace(string(body))
if detail == "" {
detail = resp.Status
}
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, detail)
}
var releases []ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("failed to decode releases: %w", err)
}
// Find latest release based on channel
// RC channel: return newest release (RC or stable), even if not newer than current
// Stable channel: return newest stable release, even if not newer than current
// The caller will determine if it's actually an update by comparing versions
if channel == "rc" {
// For RC channel: find newest release (RC or stable)
// RC users should see both RCs and stable releases
var newestRC *ReleaseInfo
var newestStable *ReleaseInfo
for i := range releases {
// Skip draft releases
if releases[i].Draft {
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release")
continue
}
releaseVer, err := ParseVersion(releases[i].TagName)
if err != nil {
log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version")
continue
}
if releases[i].Prerelease {
// Track newest RC
if newestRC == nil {
newestRC = &releases[i]
} else {
newestRCVer, _ := ParseVersion(newestRC.TagName)
if releaseVer.IsNewerThan(newestRCVer) {
newestRC = &releases[i]
}
}
} else {
// Track newest stable
if newestStable == nil {
newestStable = &releases[i]
} else {
newestStableVer, _ := ParseVersion(newestStable.TagName)
if releaseVer.IsNewerThan(newestStableVer) {
newestStable = &releases[i]
}
}
}
}
// Return the highest version among candidates
// Stable versions are considered higher than RCs (4.22.0 > 4.22.0-rc.3)
if newestStable != nil && newestRC != nil {
stableVer, _ := ParseVersion(newestStable.TagName)
rcVer, _ := ParseVersion(newestRC.TagName)
if stableVer.IsNewerThan(rcVer) {
isUpdate := stableVer.IsNewerThan(currentVer)
if isUpdate {
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for RC user")
} else {
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
}
return newestStable, nil
}
isUpdate := rcVer.IsNewerThan(currentVer)
if isUpdate {
log.Info().Str("version", newestRC.TagName).Msg("Found RC update")
} else {
log.Info().Str("version", newestRC.TagName).Msg("On latest RC version")
}
return newestRC, nil
} else if newestStable != nil {
isUpdate := newestStable.TagName != currentVer.String()
if isUpdate {
log.Info().Str("version", newestStable.TagName).Msg("Found stable update for RC user")
} else {
log.Info().Str("version", newestStable.TagName).Msg("On latest stable version")
}
return newestStable, nil
} else if newestRC != nil {
isUpdate := newestRC.TagName != currentVer.String()
if isUpdate {
log.Info().Str("version", newestRC.TagName).Msg("Found RC update")
} else {
log.Info().Str("version", newestRC.TagName).Msg("On latest RC version")
}
return newestRC, nil
}
} else {
// For stable channel: find latest non-prerelease
for i := range releases {
// Skip draft releases
if releases[i].Draft {
log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release")
continue
}
if releases[i].Prerelease {
continue
}
releaseVer, err := ParseVersion(releases[i].TagName)
if err != nil {
log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version")
continue
}
// Found the latest stable release
isUpdate := releaseVer.IsNewerThan(currentVer)
if isUpdate {
log.Info().Str("version", releases[i].TagName).Msg("Found stable update")
} else {
log.Info().Str("version", releases[i].TagName).Msg("On latest stable version")
}
return &releases[i], nil
}
}
// No releases found at all for this channel
log.Warn().Str("channel", channel).Msg("No releases found for channel")
return nil, fmt.Errorf("no releases found for channel %s", channel)
}
func (m *Manager) resolveChannel(requested string, currentInfo *VersionInfo) string {
if requested != "" {
return requested
}
if m.config != nil && m.config.UpdateChannel != "" {
return m.config.UpdateChannel
}
if currentInfo != nil && currentInfo.Channel != "" {
return currentInfo.Channel
}
return "stable"
}
// getLatestReleaseFromFeed fetches the latest release from GitHub's Atom feed
// This is used as a fallback when the API is rate-limited, as the Atom feed
// doesn't count against API rate limits.
func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string) (*ReleaseInfo, error) {
feedURL := "https://github.com/rcourtman/Pulse/releases.atom"
req, err := http.NewRequestWithContext(ctx, "GET", feedURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create feed request: %w", err)
}
req.Header.Set("User-Agent", "Pulse-Update-Checker")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read feed: %w", err)
}
// Parse the Atom feed to extract version tags
// The feed format includes entries like: <title>Pulse v5.0.0</title>
// We use simple string parsing rather than a full XML parser for minimal deps
content := string(body)
// Find all version tags in the feed (format: "Pulse vX.Y.Z" or "Pulse vX.Y.Z-rc.N")
versionRegex := regexp.MustCompile(`<title>Pulse (v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)</title>`)
matches := versionRegex.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
return nil, fmt.Errorf("no version tags found in feed")
}
// Filter based on channel
for _, match := range matches {
if len(match) < 2 {
continue
}
tagName := match[1]
// Parse the version to check if it's a prerelease
ver, err := ParseVersion(tagName)
if err != nil {
continue
}
isPrerelease := ver.IsPrerelease()
// For stable channel, skip prereleases
if channel == "stable" && isPrerelease {
continue
}
// Found a valid release for this channel
log.Debug().
Str("tag", tagName).
Bool("prerelease", isPrerelease).
Str("channel", channel).
Msg("Found release from feed")
return &ReleaseInfo{
TagName: tagName,
Name: "Pulse " + tagName,
Prerelease: isPrerelease,
// Note: Feed doesn't include full release notes or asset info
// This is just for version checking - actual download still uses known URL patterns
}, nil
}
return nil, fmt.Errorf("no suitable release found for channel %s", channel)
}
func (m *Manager) createHistoryEntry(ctx context.Context, entry UpdateHistoryEntry) string {
if m.history == nil {
return ""
}
eventID, err := m.history.CreateEntry(ctx, entry)
if err != nil {
log.Error().Err(err).Msg("Failed to create update history entry")
return ""
}
return eventID
}
func (m *Manager) updateHistoryEntry(ctx context.Context, eventID string, updateFn func(entry *UpdateHistoryEntry)) {
if m.history == nil || eventID == "" {
return
}
if err := m.history.UpdateEntry(ctx, eventID, func(e *UpdateHistoryEntry) error {
updateFn(e)
return nil
}); err != nil {
log.Error().Err(err).Str("event_id", eventID).Msg("Failed to update history entry")
}
}
func (m *Manager) completeHistoryEntry(ctx context.Context, eventID string, status UpdateStatusType, start time.Time, runErr error) {
if m.history == nil || eventID == "" {
return
}
if err := m.history.UpdateEntry(ctx, eventID, func(e *UpdateHistoryEntry) error {
e.Status = status
e.DurationMs = time.Since(start).Milliseconds()
if runErr != nil {
e.Error = &UpdateError{
Message: runErr.Error(),
Code: "update_failed",
}
} else {
e.Error = nil
}
return nil
}); err != nil {
log.Error().Err(err).Str("event_id", eventID).Msg("Failed to finalize history entry")
}
}
var versionInURLRegex = regexp.MustCompile(`v\d+\.\d+\.\d+(?:-[A-Za-z0-9\.]*\d[A-Za-z0-9\.]*)?`)
func inferVersionFromDownloadURL(downloadURL string) string {
if downloadURL == "" {
return ""
}
if match := versionInURLRegex.FindString(downloadURL); match != "" {
return match
}
base := filepath.Base(downloadURL)
if match := versionInURLRegex.FindString(base); match != "" {
return match
}
return ""
}
// downloadFile downloads a file from URL to dest
func (m *Manager) downloadFile(ctx context.Context, url, dest string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return 0, err
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("download failed with status %d", resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return 0, err
}
defer out.Close()
// Copy with progress updates
written, err := io.Copy(out, resp.Body)
if err != nil {
return 0, err
}
log.Info().Int64("bytes", written).Str("file", dest).Msg("Downloaded file")
return written, nil
}
// verifyChecksum downloads and verifies the SHA256 checksum of a file
func (m *Manager) verifyChecksum(ctx context.Context, tarballURL, tarballPath string) error {
// Try to find checksum file URL by deriving from tarball URL
// Example: pulse-v4.22.0-linux-amd64.tar.gz -> SHA256SUMS or checksums.txt
baseURL := tarballURL[:strings.LastIndex(tarballURL, "/")+1]
// Common checksum file names used in GitHub releases
checksumNames := []string{"SHA256SUMS", "checksums.txt", "SHA256SUMS.txt"}
var checksumContent string
var checksumErr error
// Try each checksum filename
for _, name := range checksumNames {
checksumURL := baseURL + name
req, err := http.NewRequestWithContext(ctx, "GET", checksumURL, nil)
if err != nil {
continue
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err == nil {
checksumContent = string(body)
log.Info().Str("file", name).Msg("Found checksum file")
break
}
}
}
if checksumContent == "" {
return fmt.Errorf("no checksum file found")
}
// Parse checksum file to find the hash for our tarball
tarballName := filepath.Base(tarballURL)
expectedHash := ""
for _, line := range strings.Split(checksumContent, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Format: "hash filename" or "hash *filename"
parts := strings.Fields(line)
if len(parts) >= 2 {
hash := parts[0]
filename := parts[1]
// Remove leading * if present (indicates binary mode)
filename = strings.TrimPrefix(filename, "*")
if filename == tarballName {
expectedHash = strings.ToLower(hash)
break
}
}
}
if expectedHash == "" {
return fmt.Errorf("checksum not found for %s in checksum file", tarballName)
}
// Compute SHA256 of downloaded file
file, err := os.Open(tarballPath)
if err != nil {
return fmt.Errorf("failed to open tarball for checksum: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("failed to compute checksum: %w", err)
}
actualHash := hex.EncodeToString(hash.Sum(nil))
// Compare hashes
if actualHash != expectedHash {
checksumErr = fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
log.Error().
Str("expected", expectedHash).
Str("actual", actualHash).
Msg("Checksum verification failed")
return checksumErr
}
log.Info().
Str("hash", actualHash).
Msg("Checksum verified successfully")
return nil
}
// extractTarball extracts a gzipped tarball
func (m *Manager) extractTarball(src, dest string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
// Sanitize the path to prevent directory traversal attacks
cleanName := filepath.Clean(header.Name)
// Check for path traversal attempts
if strings.Contains(cleanName, "..") || filepath.IsAbs(cleanName) {
return fmt.Errorf("unsafe path in archive: %s", header.Name)
}
// Ensure the target path is within the destination directory
target := filepath.Join(dest, cleanName)
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) && target != filepath.Clean(dest) {
return fmt.Errorf("path escapes destination directory: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
}
}
return nil
}
// createBackup creates a backup of the current installation
func (m *Manager) createBackup() (string, error) {
timestamp := time.Now().Format("20060102-150405")
// Try to create backup in a writable location
dataDir := os.Getenv("PULSE_DATA_DIR")
if dataDir == "" {
dataDir = "/etc/pulse"
}
backupDir := filepath.Join(dataDir, fmt.Sprintf("backup-%s", timestamp))
// Create backup directory
if err := os.MkdirAll(backupDir, 0755); err != nil {
// Fallback to /tmp if data dir fails
backupDir = fmt.Sprintf("/tmp/pulse-backup-%s", timestamp)
if err := os.MkdirAll(backupDir, 0755); err != nil {
return "", fmt.Errorf("failed to create backup directory: %w", err)
}
}
// Backup important directories
dirsToBackup := []string{"data", "config"}
pulseDir := "/opt/pulse"
for _, dir := range dirsToBackup {
src := filepath.Join(pulseDir, dir)
dest := filepath.Join(backupDir, dir)
if _, err := os.Stat(src); err == nil {
if err := m.copyDirSafe(src, dest); err != nil {
log.Warn().Str("dir", dir).Err(err).Msg("Failed to backup directory")
}
}
}
// Backup .env file
envSrc := filepath.Join(pulseDir, ".env")
if _, err := os.Stat(envSrc); err == nil {
envDest := filepath.Join(backupDir, ".env")
if err := m.copyFileSafe(envSrc, envDest); err != nil {
log.Warn().Err(err).Msg("Failed to backup .env file")
}
}
// Backup the pulse binary itself
binaryPath, err := os.Executable()
if err == nil {
binaryDest := filepath.Join(backupDir, "pulse")
if err := m.copyFileSafe(binaryPath, binaryDest); err != nil {
log.Warn().Err(err).Msg("Failed to backup pulse binary")
} else {
log.Info().Str("binary", binaryPath).Msg("Backed up pulse binary")
}
}
// Backup VERSION file if it exists
versionSrc := filepath.Join(pulseDir, "VERSION")
if _, err := os.Stat(versionSrc); err == nil {
versionDest := filepath.Join(backupDir, "VERSION")
if err := m.copyFileSafe(versionSrc, versionDest); err != nil {
log.Warn().Err(err).Msg("Failed to backup VERSION file")
}
}
return backupDir, nil
}
// restoreBackup restores from a backup
func (m *Manager) restoreBackup(backupDir string) error {
pulseDir := "/opt/pulse"
// Restore directories
dirsToRestore := []string{"data", "config"}
for _, dir := range dirsToRestore {
src := filepath.Join(backupDir, dir)
dest := filepath.Join(pulseDir, dir)
if _, err := os.Stat(src); err == nil {
// Remove existing directory first
if err := os.RemoveAll(dest); err != nil {
return fmt.Errorf("failed to remove existing %s: %w", dir, err)
}
if err := m.copyDirSafe(src, dest); err != nil {
return fmt.Errorf("failed to restore %s: %w", dir, err)
}
}
}
// Restore .env
envSrc := filepath.Join(backupDir, ".env")
if _, err := os.Stat(envSrc); err == nil {
envDest := filepath.Join(pulseDir, ".env")
if err := m.copyFileSafe(envSrc, envDest); err != nil {
return fmt.Errorf("failed to restore .env: %w", err)
}
}
// Restore the pulse binary if it exists in backup
binarySrc := filepath.Join(backupDir, "pulse")
if _, err := os.Stat(binarySrc); err == nil {
binaryPath, err := os.Executable()
if err == nil {
// Create temp copy first, then atomic rename
tempBinary := binaryPath + ".restored"
if err := m.copyFileSafe(binarySrc, tempBinary); err != nil {
return fmt.Errorf("failed to restore pulse binary: %w", err)
}
if err := os.Chmod(tempBinary, 0755); err != nil {
return fmt.Errorf("failed to set binary permissions: %w", err)
}
if err := os.Rename(tempBinary, binaryPath); err != nil {
return fmt.Errorf("failed to replace binary: %w", err)
}
log.Info().Str("binary", binaryPath).Msg("Restored pulse binary")
}
}
// Restore VERSION file if it exists in backup
versionSrc := filepath.Join(backupDir, "VERSION")
if _, err := os.Stat(versionSrc); err == nil {
versionDest := filepath.Join(pulseDir, "VERSION")
if err := m.copyFileSafe(versionSrc, versionDest); err != nil {
log.Warn().Err(err).Msg("Failed to restore VERSION file")
}
}
return nil
}
// applyUpdateFiles copies update files to the installation directory
func (m *Manager) applyUpdateFiles(extractDir string) error {
// Check for pulse binary in both old (root) and new (bin/) locations
pulseBinary := filepath.Join(extractDir, "pulse")
if _, err := os.Stat(pulseBinary); err != nil {
// Try new structure with bin/ directory
pulseBinary = filepath.Join(extractDir, "bin", "pulse")
if _, err := os.Stat(pulseBinary); err != nil {
return fmt.Errorf("pulse binary not found in extract (checked both / and /bin/): %w", err)
}
}
// Detect where the current binary is running from
binaryPath, err := os.Executable()
if err != nil {
// Fallback to default location
binaryPath = "/usr/local/bin/pulse"
}
// Copy the pulse binary to a temporary location first, then move atomically
tempBinary := binaryPath + ".new"
cmd := exec.Command("cp", pulseBinary, tempBinary)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to copy pulse binary: %w", err)
}
// Make it executable
if err := os.Chmod(tempBinary, 0755); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
// Atomically replace the old binary with the new one
if err := os.Rename(tempBinary, binaryPath); err != nil {
// If rename fails (cross-device), try mv command
cmd = exec.Command("mv", "-f", tempBinary, binaryPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to replace pulse binary: %w", err)
}
}
// Frontend is now embedded in the binary as of v4.2.2+
// No need to copy frontend files separately
// The new binary contains everything needed
// Copy VERSION file if it exists (to both locations for compatibility)
versionSrc := filepath.Join(extractDir, "VERSION")
if _, err := os.Stat(versionSrc); err == nil {
// Copy to /opt/pulse
cmd = exec.Command("cp", versionSrc, "/opt/pulse/VERSION")
if err := cmd.Run(); err != nil {
log.Debug().Err(err).Msg("Failed to copy VERSION to /opt/pulse")
}
// Copy to binary directory
binaryDir := filepath.Dir(binaryPath)
cmd = exec.Command("cp", versionSrc, filepath.Join(binaryDir, "VERSION"))
if err := cmd.Run(); err != nil {
log.Warn().Err(err).Msg("Failed to copy VERSION file")
}
}
// Deploy agent installation scripts from tarball
scriptsDir := filepath.Join(extractDir, "scripts")
if _, err := os.Stat(scriptsDir); err == nil {
destScriptsDir := "/opt/pulse/scripts"
if err := os.MkdirAll(destScriptsDir, 0755); err != nil {
log.Warn().Err(err).Msg("Failed to create scripts directory")
} else {
// List of agent scripts to deploy
agentScripts := []string{
"install-docker-agent.sh",
"install-container-agent.sh",
"install-host-agent.ps1",
"uninstall-host-agent.sh",
"uninstall-host-agent.ps1",
"install-sensor-proxy.sh",
"install-docker.sh",
"install.sh",
"install.ps1",
}
deployed := 0
for _, script := range agentScripts {
srcPath := filepath.Join(scriptsDir, script)
if _, err := os.Stat(srcPath); err == nil {
destPath := filepath.Join(destScriptsDir, script)
cmd = exec.Command("cp", srcPath, destPath)
if err := cmd.Run(); err != nil {
log.Warn().Err(err).Str("script", script).Msg("Failed to copy agent script")
continue
}
if err := os.Chmod(destPath, 0755); err != nil {
log.Warn().Err(err).Str("script", script).Msg("Failed to set script permissions")
}
deployed++
}
}
if deployed > 0 {
log.Info().Int("count", deployed).Msg("Deployed agent installation scripts")
}
}
}
// Deploy agent binaries from tarball (for serving to remote hosts)
binDir := filepath.Join(extractDir, "bin")
if _, err := os.Stat(binDir); err == nil {
destBinDir := "/opt/pulse/bin"
if err := os.MkdirAll(destBinDir, 0755); err != nil {
log.Warn().Err(err).Msg("Failed to create bin directory")
} else {
// Copy agent binaries (pulse-agent-*, pulse-docker-agent-*, pulse-host-agent-*)
entries, err := os.ReadDir(binDir)
if err == nil {
agentBinariesDeployed := 0
for _, entry := range entries {
name := entry.Name()
// Skip the main pulse binary (already handled above) and directories
if entry.IsDir() || name == "pulse" {
continue
}
// Copy agent binaries
if strings.HasPrefix(name, "pulse-agent-") ||
strings.HasPrefix(name, "pulse-docker-agent") ||
strings.HasPrefix(name, "pulse-host-agent") ||
name == "pulse-sensor-proxy" {
srcPath := filepath.Join(binDir, name)
destPath := filepath.Join(destBinDir, name)
cmd = exec.Command("cp", "-a", srcPath, destPath)
if err := cmd.Run(); err != nil {
log.Warn().Err(err).Str("binary", name).Msg("Failed to copy agent binary")
continue
}
// Set executable permission (skip for symlinks)
if info, err := os.Lstat(destPath); err == nil && info.Mode()&os.ModeSymlink == 0 {
if err := os.Chmod(destPath, 0755); err != nil {
log.Warn().Err(err).Str("binary", name).Msg("Failed to set binary permissions")
}
}
agentBinariesDeployed++
}
}
if agentBinariesDeployed > 0 {
log.Info().Int("count", agentBinariesDeployed).Msg("Deployed agent binaries")
}
}
}
}
// Set ownership if /opt/pulse exists
if _, err := os.Stat("/opt/pulse"); err == nil {
cmd = exec.Command("chown", "-R", "pulse:pulse", "/opt/pulse")
if err := cmd.Run(); err != nil {
log.Warn().Err(err).Msg("Failed to set ownership")
}
}
return nil
}
// updateStatus updates the current status
func (m *Manager) updateStatus(status string, progress int, message string, err ...error) {
m.statusMu.Lock()
m.status = UpdateStatus{
Status: status,
Progress: progress,
Message: message,
UpdatedAt: time.Now().Format(time.RFC3339),
}
// If error provided, sanitize and add to status
if len(err) > 0 && err[0] != nil {
m.status.Error = sanitizeError(err[0])
}
statusCopy := m.status
m.statusMu.Unlock()
// Send to progress channel (non-blocking) for WebSocket compatibility
select {
case m.progressChan <- statusCopy:
default:
}
// Broadcast to SSE clients
if m.sseBroadcast != nil {
m.sseBroadcast.Broadcast(statusCopy)
}
if delay := statusDelayForStage(status); delay > 0 {
time.Sleep(delay)
}
}
// sseHeartbeatLoop sends periodic heartbeats to SSE clients
func (m *Manager) sseHeartbeatLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if m.sseBroadcast != nil {
m.sseBroadcast.SendHeartbeat()
}
}
}
// sanitizeError removes potentially sensitive information from error messages
func sanitizeError(err error) string {
if err == nil {
return ""
}
errMsg := err.Error()
// Cap length to prevent extremely long error messages
maxLen := 500
if len(errMsg) > maxLen {
errMsg = errMsg[:maxLen] + "..."
}
return errMsg
}
func statusDelayForStage(status string) time.Duration {
delay := configuredStageDelay()
if delay == 0 {
return 0
}
switch status {
case "downloading", "verifying", "extracting", "backing-up", "applying":
return delay
default:
return 0
}
}
func configuredStageDelay() time.Duration {
stageDelayOnce.Do(func() {
value := strings.TrimSpace(os.Getenv("PULSE_UPDATE_STAGE_DELAY_MS"))
if value == "" {
return
}
ms, err := strconv.Atoi(value)
if err != nil || ms <= 0 {
log.Warn().Str("value", value).Msg("Invalid PULSE_UPDATE_STAGE_DELAY_MS, ignoring")
return
}
stageDelayValue = time.Duration(ms) * time.Millisecond
})
return stageDelayValue
}
// cleanupOldTempDirs removes old pulse-update-* temp directories from previous runs
func (m *Manager) cleanupOldTempDirs() {
// Check multiple locations where temp dirs might exist
dataDir := os.Getenv("PULSE_DATA_DIR")
if dataDir == "" {
dataDir = "/etc/pulse"
}
dirsToCheck := []string{"/tmp", dataDir, "."}
for _, dir := range dirsToCheck {
entries, err := os.ReadDir(dir)
if err != nil {
continue // Directory not accessible, skip
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Check if it matches pulse-update-* pattern
if !strings.HasPrefix(entry.Name(), "pulse-update-") {
continue
}
fullPath := filepath.Join(dir, entry.Name())
info, err := os.Stat(fullPath)
if err != nil {
continue
}
// Remove directories older than 24 hours
if time.Since(info.ModTime()) > 24*time.Hour {
if err := os.RemoveAll(fullPath); err != nil {
log.Debug().Err(err).Str("path", fullPath).Msg("Failed to cleanup old temp directory")
} else {
log.Info().Str("path", fullPath).Msg("Cleaned up old temp directory")
}
}
}
}
}
// copyFileSafe safely copies a file, skipping symlinks for security
func (m *Manager) copyFileSafe(src, dest string) error {
// Get file info and check if it's a symlink
info, err := os.Lstat(src)
if err != nil {
return err
}
// Skip symlinks for security
if info.Mode()&os.ModeSymlink != 0 {
log.Warn().Str("file", src).Msg("Skipping symlink during backup/restore")
return nil
}
// Open source file
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
// Create destination file with same permissions
destFile, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer destFile.Close()
// Copy contents
if _, err := io.Copy(destFile, srcFile); err != nil {
return err
}
return nil
}
// copyDirSafe recursively copies a directory, skipping symlinks for security
func (m *Manager) copyDirSafe(src, dest string) error {
// Get source directory info
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
// Create destination directory
if err := os.MkdirAll(dest, srcInfo.Mode()); err != nil {
return err
}
// Read source directory entries
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
destPath := filepath.Join(dest, entry.Name())
// Get file info (using Lstat to detect symlinks)
info, err := os.Lstat(srcPath)
if err != nil {
log.Warn().Str("path", srcPath).Err(err).Msg("Failed to stat file during copy")
continue
}
// Skip symlinks for security
if info.Mode()&os.ModeSymlink != 0 {
log.Warn().Str("path", srcPath).Msg("Skipping symlink during backup/restore")
continue
}
if entry.IsDir() {
// Recursively copy subdirectory
if err := m.copyDirSafe(srcPath, destPath); err != nil {
return err
}
} else {
// Copy file
if err := m.copyFileSafe(srcPath, destPath); err != nil {
log.Warn().Str("file", srcPath).Err(err).Msg("Failed to copy file")
continue
}
}
}
return nil
}
// isPreV4Installation checks if this is a pre-v4 (Node.js based) installation
func isPreV4Installation() bool {
// Check for .env file (used by Node.js version)
if _, err := os.Stat("/opt/pulse/.env"); err == nil {
return true
}
// Note: pulse-backend.service is used by both v4 and pre-v4, so we can't use it as an indicator
// Only check for Node.js artifacts which are exclusive to pre-v4
// Check for Node.js artifacts
nodeArtifacts := []string{
"/opt/pulse/package.json",
"/opt/pulse/node_modules",
"/opt/pulse/server.js",
"/opt/pulse/backend",
"/opt/pulse/frontend",
}
for _, artifact := range nodeArtifacts {
if _, err := os.Stat(artifact); err == nil {
return true
}
}
return false
}