mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-12 05:45:27 +00:00
- Support both old (root) and new (bin/) tarball structures - Use writable directories for temp and backup files (data dir instead of /tmp) - Fixes update failures for users on v4.1.5 and earlier
718 lines
No EOL
20 KiB
Go
718 lines
No EOL
20 KiB
Go
package updates
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"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"`
|
|
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"`
|
|
}
|
|
|
|
// Manager handles update operations
|
|
type Manager struct {
|
|
config *config.Config
|
|
status UpdateStatus
|
|
statusMu sync.RWMutex
|
|
checkCache *UpdateInfo
|
|
cacheTime time.Time
|
|
cacheDuration time.Duration
|
|
progressChan chan UpdateStatus
|
|
}
|
|
|
|
// NewManager creates a new update manager
|
|
func NewManager(cfg *config.Config) *Manager {
|
|
return &Manager{
|
|
config: cfg,
|
|
cacheDuration: 5 * time.Minute, // Cache update checks for 5 minutes
|
|
progressChan: make(chan UpdateStatus, 100),
|
|
status: UpdateStatus{
|
|
Status: "idle",
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetProgressChannel returns the channel for update progress
|
|
func (m *Manager) GetProgressChannel() <-chan UpdateStatus {
|
|
return m.progressChan
|
|
}
|
|
|
|
// 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) {
|
|
// Use provided channel or fall back to config
|
|
if channel == "" {
|
|
channel = m.config.UpdateChannel
|
|
}
|
|
if channel == "" {
|
|
channel = "stable"
|
|
}
|
|
|
|
// Don't use cache when channel is explicitly provided (UI might have changed it)
|
|
useCache := channel == m.config.UpdateChannel || channel == ""
|
|
|
|
// Check cache first (only if using saved channel)
|
|
if useCache && m.checkCache != nil && time.Since(m.cacheTime) < m.cacheDuration {
|
|
return m.checkCache, nil
|
|
}
|
|
|
|
m.updateStatus("checking", 0, "Checking for updates...")
|
|
|
|
// Get current version
|
|
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)
|
|
}
|
|
|
|
// Skip update check for Docker
|
|
if currentInfo.IsDocker {
|
|
info := &UpdateInfo{
|
|
Available: false,
|
|
CurrentVersion: currentInfo.Version,
|
|
LatestVersion: currentInfo.Version,
|
|
}
|
|
if useCache {
|
|
m.checkCache = info
|
|
m.cacheTime = time.Now()
|
|
}
|
|
m.updateStatus("idle", 0, "Updates not available in Docker")
|
|
return info, nil
|
|
}
|
|
|
|
// Get latest release from GitHub with specified channel
|
|
release, err := m.getLatestReleaseForChannel(ctx, channel)
|
|
if err != nil {
|
|
m.updateStatus("error", 0, "Failed to check for updates")
|
|
return nil, err
|
|
}
|
|
|
|
// Parse versions
|
|
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)
|
|
}
|
|
|
|
latestVer, err := ParseVersion(release.TagName)
|
|
if err != nil {
|
|
m.updateStatus("error", 0, "Invalid latest version")
|
|
return nil, fmt.Errorf("failed to parse latest version: %w", err)
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
|
|
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.checkCache = info
|
|
m.cacheTime = time.Now()
|
|
}
|
|
|
|
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, downloadURL string) error {
|
|
// Validate download URL (allow test server URLs when PULSE_UPDATE_SERVER is set)
|
|
if os.Getenv("PULSE_UPDATE_SERVER") == "" {
|
|
if !strings.HasPrefix(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")
|
|
}
|
|
|
|
m.updateStatus("downloading", 10, "Downloading update...")
|
|
|
|
// 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 {
|
|
m.updateStatus("error", 10, "Failed to create temp directory")
|
|
return fmt.Errorf("failed to create temp directory in any location: %w", err)
|
|
}
|
|
}
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Download update
|
|
tarballPath := filepath.Join(tempDir, "update.tar.gz")
|
|
if err := m.downloadFile(ctx, downloadURL, tarballPath); err != nil {
|
|
m.updateStatus("error", 20, "Failed to download update")
|
|
return fmt.Errorf("failed to download update: %w", err)
|
|
}
|
|
|
|
m.updateStatus("extracting", 40, "Extracting update...")
|
|
|
|
// Extract tarball
|
|
extractDir := filepath.Join(tempDir, "extracted")
|
|
if err := m.extractTarball(tarballPath, extractDir); err != nil {
|
|
m.updateStatus("error", 40, "Failed to extract update")
|
|
return fmt.Errorf("failed to extract update: %w", err)
|
|
}
|
|
|
|
m.updateStatus("backing-up", 60, "Creating backup...")
|
|
|
|
// Create backup
|
|
backupPath, err := m.createBackup()
|
|
if err != nil {
|
|
m.updateStatus("error", 60, "Failed to create backup")
|
|
return fmt.Errorf("failed to create backup: %w", err)
|
|
}
|
|
log.Info().Str("backup", backupPath).Msg("Created backup")
|
|
|
|
m.updateStatus("applying", 80, "Applying update...")
|
|
|
|
// Extract version from download URL or use timestamp
|
|
version := "unknown"
|
|
if parts := strings.Split(downloadURL, "/"); len(parts) > 0 {
|
|
for _, part := range parts {
|
|
if strings.HasPrefix(part, "v") {
|
|
version = strings.TrimPrefix(part, "v")
|
|
version = strings.TrimSuffix(version, ".tar.gz")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
m.updateStatus("error", 80, "Failed to apply update")
|
|
// Attempt to restore backup
|
|
if restoreErr := m.restoreBackup(backupPath); restoreErr != nil {
|
|
log.Error().Err(restoreErr).Msg("Failed to restore backup")
|
|
}
|
|
return fmt.Errorf("failed to apply update: %w", err)
|
|
}
|
|
|
|
m.updateStatus("restarting", 95, "Restarting service...")
|
|
|
|
// Schedule a clean exit after a short delay - systemd will restart us
|
|
go func() {
|
|
time.Sleep(2 * time.Second)
|
|
log.Info().Msg("Exiting for restart after update")
|
|
os.Exit(0)
|
|
}()
|
|
|
|
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
|
|
}
|
|
|
|
// getLatestRelease fetches the latest release from GitHub using saved config
|
|
func (m *Manager) getLatestRelease(ctx context.Context) (*ReleaseInfo, error) {
|
|
channel := m.config.UpdateChannel
|
|
if channel == "" {
|
|
channel = "stable"
|
|
}
|
|
return m.getLatestReleaseForChannel(ctx, channel)
|
|
}
|
|
|
|
// getLatestReleaseForChannel fetches the latest release from GitHub for a specific channel
|
|
func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string) (*ReleaseInfo, error) {
|
|
if channel == "" {
|
|
channel = "stable"
|
|
}
|
|
|
|
log.Info().Str("channel", channel).Msg("Checking for updates")
|
|
|
|
// GitHub API URL (can be overridden for testing)
|
|
baseURL := os.Getenv("PULSE_UPDATE_SERVER")
|
|
if baseURL == "" {
|
|
baseURL = "https://api.github.com"
|
|
}
|
|
url := baseURL + "/repos/rcourtman/Pulse/releases"
|
|
if channel == "stable" {
|
|
url += "/latest"
|
|
}
|
|
|
|
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.StatusOK {
|
|
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
if channel == "stable" {
|
|
var release ReleaseInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
|
return nil, fmt.Errorf("failed to decode release: %w", err)
|
|
}
|
|
return &release, nil
|
|
}
|
|
|
|
// For RC channel, get all releases and find latest
|
|
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
|
|
if channel == "rc" {
|
|
// For RC channel, return the first release (newest by creation date)
|
|
// GitHub API returns releases sorted by created_at desc
|
|
if len(releases) > 0 {
|
|
return &releases[0], nil
|
|
}
|
|
} else {
|
|
// For stable channel, find the first non-prerelease
|
|
for i := range releases {
|
|
if !releases[i].Prerelease {
|
|
return &releases[i], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("no releases found for channel %s", channel)
|
|
}
|
|
|
|
// downloadFile downloads a file from URL to dest
|
|
func (m *Manager) downloadFile(ctx context.Context, url, dest string) error {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 5 * time.Minute}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
// Copy with progress updates
|
|
written, err := io.Copy(out, resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Info().Int64("bytes", written).Str("file", dest).Msg("Downloaded file")
|
|
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
|
|
}
|
|
|
|
target := filepath.Join(dest, 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 {
|
|
cmd := exec.Command("cp", "-r", src, dest)
|
|
if err := cmd.Run(); 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")
|
|
cmd := exec.Command("cp", envSrc, envDest)
|
|
cmd.Run()
|
|
}
|
|
|
|
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 {
|
|
cmd := exec.Command("cp", "-r", src, dest)
|
|
if err := cmd.Run(); 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")
|
|
cmd := exec.Command("cp", envSrc, envDest)
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to restore .env: %w", err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Copy frontend directory if it exists (check both old and new locations)
|
|
// Note: Frontend is usually embedded in the binary for v4, but copy for compatibility
|
|
frontendSrc := filepath.Join(extractDir, "frontend-modern")
|
|
if _, err := os.Stat(frontendSrc); err != nil {
|
|
// Try new structure with bin/ directory
|
|
frontendSrc = filepath.Join(extractDir, "bin", "frontend-modern")
|
|
}
|
|
if _, err := os.Stat(frontendSrc); err == nil {
|
|
// Determine frontend destination based on binary location
|
|
binaryDir := filepath.Dir(binaryPath)
|
|
frontendDst := filepath.Join(binaryDir, "frontend-modern")
|
|
|
|
cmd = exec.Command("cp", "-r", frontendSrc, frontendDst+".new")
|
|
if err := cmd.Run(); err == nil {
|
|
// Remove old and rename new
|
|
os.RemoveAll(frontendDst)
|
|
os.Rename(frontendDst+".new", frontendDst)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
cmd.Run() // Ignore error, this location might not exist
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
m.statusMu.Lock()
|
|
m.status = UpdateStatus{
|
|
Status: status,
|
|
Progress: progress,
|
|
Message: message,
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
statusCopy := m.status
|
|
m.statusMu.Unlock()
|
|
|
|
// Send to progress channel (non-blocking)
|
|
select {
|
|
case m.progressChan <- statusCopy:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Check for old service names
|
|
cmd := exec.Command("systemctl", "list-unit-files", "--no-legend", "pulse-backend.service")
|
|
if output, err := cmd.Output(); err == nil && len(output) > 0 {
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
} |