package updates import ( "bufio" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "github.com/rs/zerolog/log" ) // InstallShAdapter wraps the install.sh script for systemd/LXC deployments type InstallShAdapter struct { history *UpdateHistory installScriptURL string logDir string } // NewInstallShAdapter creates a new install.sh adapter func NewInstallShAdapter(history *UpdateHistory) *InstallShAdapter { return &InstallShAdapter{ history: history, installScriptURL: "https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh", logDir: "/var/log/pulse", } } // SupportsApply returns true for systemd and proxmoxve deployments func (a *InstallShAdapter) SupportsApply() bool { return true } // GetDeploymentType returns the deployment type func (a *InstallShAdapter) GetDeploymentType() string { return "systemd" // Can be "systemd" or "proxmoxve" } // PrepareUpdate returns update plan information func (a *InstallShAdapter) PrepareUpdate(ctx context.Context, request UpdateRequest) (*UpdatePlan, error) { plan := &UpdatePlan{ CanAutoUpdate: true, RequiresRoot: true, RollbackSupport: true, EstimatedTime: "2-5 minutes", Instructions: []string{ fmt.Sprintf("Download and install Pulse %s", request.Version), "Create backup of current installation", "Extract and apply update", "Restart Pulse service", }, Prerequisites: []string{ "Root access (sudo)", "Internet connection", "At least 100MB free disk space", }, } return plan, nil } // Execute performs the update by calling install.sh func (a *InstallShAdapter) Execute(ctx context.Context, request UpdateRequest, progressCb ProgressCallback) error { // Ensure log directory exists if err := os.MkdirAll(a.logDir, 0755); err != nil { return fmt.Errorf("failed to create log directory: %w", err) } // Create log file logFile := filepath.Join(a.logDir, fmt.Sprintf("update-%s.log", time.Now().Format("20060102-150405"))) logFd, err := os.Create(logFile) if err != nil { return fmt.Errorf("failed to create log file: %w", err) } defer logFd.Close() // Download install script progressCb(UpdateProgress{ Stage: "downloading", Progress: 10, Message: "Downloading installation script...", }) log.Info(). Str("url", a.installScriptURL). Str("version", request.Version). Msg("Downloading install script") installScript, err := a.downloadInstallScript(ctx) if err != nil { return fmt.Errorf("failed to download install script: %w", err) } // Prepare command progressCb(UpdateProgress{ Stage: "preparing", Progress: 20, Message: "Preparing update...", }) // Validate version string to prevent command injection // Version must match semantic versioning format (with optional 'v' prefix) versionPattern := regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$`) if !versionPattern.MatchString(request.Version) { return fmt.Errorf("invalid version format: %s", request.Version) } // Build command: bash install.sh --version vX.Y.Z args := []string{"-s", "--", "--version", request.Version} if request.Force { args = append(args, "--force") } cmd := exec.CommandContext(ctx, "bash", args...) cmd.Stdin = strings.NewReader(installScript) // Create pipes for stdout and stderr stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("failed to create stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { return fmt.Errorf("failed to create stderr pipe: %w", err) } // Start command if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start install script: %w", err) } // Track backup path from output var backupPath string backupRe := regexp.MustCompile(`[Bb]ackup.*:\s*(.+)`) // Monitor output go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() // Write to log file fmt.Fprintln(logFd, line) // Parse for backup path if matches := backupRe.FindStringSubmatch(line); len(matches) > 1 { backupPath = strings.TrimSpace(matches[1]) } // Emit progress based on output progress := a.parseProgress(line) if progress.Message != "" { progressCb(progress) } } }() // Also capture stderr go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { line := scanner.Text() fmt.Fprintln(logFd, "STDERR:", line) } }() // Wait for completion progressCb(UpdateProgress{ Stage: "installing", Progress: 50, Message: "Installing update...", }) err = cmd.Wait() if err != nil { // Read last few lines of log for error context errorDetails := a.readLastLines(logFile, 10) progressCb(UpdateProgress{ Stage: "failed", Progress: 0, Message: "Update failed", IsComplete: true, Error: errorDetails, }) return fmt.Errorf("install script failed: %w\n%s", err, errorDetails) } progressCb(UpdateProgress{ Stage: "completed", Progress: 100, Message: "Update completed successfully", IsComplete: true, }) log.Info(). Str("version", request.Version). Str("backup", backupPath). Str("log", logFile). Msg("Update completed successfully") return nil } // Rollback rolls back to a previous version func (a *InstallShAdapter) Rollback(ctx context.Context, eventID string) error { // Get the event from history entry, err := a.history.GetEntry(eventID) if err != nil { return fmt.Errorf("failed to get history entry: %w", err) } if entry.BackupPath == "" { return fmt.Errorf("no backup path available for event %s", eventID) } // Check if backup exists if _, err := os.Stat(entry.BackupPath); os.IsNotExist(err) { return fmt.Errorf("backup not found: %s", entry.BackupPath) } targetVersion := entry.VersionFrom if targetVersion == "" { return fmt.Errorf("no target version available in history") } log.Info(). Str("event_id", eventID). Str("backup", entry.BackupPath). Str("current_version", entry.VersionTo). Str("target_version", targetVersion). Msg("Starting rollback") // Create rollback history entry rollbackEventID, err := a.history.CreateEntry(ctx, UpdateHistoryEntry{ Action: ActionRollback, VersionFrom: entry.VersionTo, VersionTo: targetVersion, DeploymentType: a.GetDeploymentType(), InitiatedBy: InitiatedByUser, InitiatedVia: InitiatedViaCLI, Status: StatusInProgress, RelatedEventID: eventID, Notes: fmt.Sprintf("Rolling back update %s", eventID), }) if err != nil { return fmt.Errorf("failed to create rollback history entry: %w", err) } rollbackErr := a.executeRollback(ctx, entry, targetVersion) // Update rollback history finalStatus := StatusSuccess var updateError *UpdateError if rollbackErr != nil { finalStatus = StatusFailed updateError = &UpdateError{ Message: rollbackErr.Error(), Code: "rollback_failed", } } _ = a.history.UpdateEntry(ctx, rollbackEventID, func(e *UpdateHistoryEntry) error { e.Status = finalStatus e.Error = updateError return nil }) return rollbackErr } // executeRollback performs the actual rollback operation func (a *InstallShAdapter) executeRollback(ctx context.Context, entry *UpdateHistoryEntry, targetVersion string) error { // Step 1: Detect service name serviceName, err := a.detectServiceName() if err != nil { return fmt.Errorf("failed to detect service name: %w", err) } log.Info().Str("service", serviceName).Msg("Detected Pulse service") // Step 2: Download old binary log.Info().Str("version", targetVersion).Msg("Downloading old binary") binaryPath, err := a.downloadBinary(ctx, targetVersion) if err != nil { return fmt.Errorf("failed to download binary: %w", err) } defer os.RemoveAll(filepath.Dir(binaryPath)) // Step 3: Stop service log.Info().Msg("Stopping Pulse service") if err := a.stopService(ctx, serviceName); err != nil { return fmt.Errorf("failed to stop service: %w", err) } // Step 4: Backup current config (safety) configDir := "/etc/pulse" safetyBackup := fmt.Sprintf("%s.rollback-safety.%s", configDir, time.Now().Format("20060102-150405")) log.Info().Str("backup", safetyBackup).Msg("Creating safety backup of current config") if err := exec.CommandContext(ctx, "cp", "-a", configDir, safetyBackup).Run(); err != nil { log.Warn().Err(err).Msg("Failed to create safety backup") } // Step 5: Restore config from backup log.Info().Str("source", entry.BackupPath).Msg("Restoring configuration") if err := a.restoreConfig(ctx, entry.BackupPath, configDir); err != nil { // Try to start service anyway _ = a.startService(ctx, serviceName) return fmt.Errorf("failed to restore config: %w", err) } // Step 6: Install old binary log.Info().Str("version", targetVersion).Msg("Installing old binary") installDir := "/opt/pulse/bin/pulse" if err := a.installBinary(ctx, binaryPath, installDir); err != nil { // Try to start service anyway _ = a.startService(ctx, serviceName) return fmt.Errorf("failed to install binary: %w", err) } // Step 7: Start service log.Info().Msg("Starting Pulse service") if err := a.startService(ctx, serviceName); err != nil { return fmt.Errorf("failed to start service: %w", err) } // Step 8: Health check log.Info().Msg("Verifying service health") if err := a.waitForHealth(ctx, 30*time.Second); err != nil { return fmt.Errorf("service health check failed: %w", err) } log.Info().Str("version", targetVersion).Msg("Rollback completed successfully") return nil } // detectServiceName detects the active Pulse service name func (a *InstallShAdapter) detectServiceName() (string, error) { candidates := []string{"pulse", "pulse-backend", "pulse-hot-dev"} for _, name := range candidates { cmd := exec.Command("systemctl", "is-active", name) if output, err := cmd.Output(); err == nil { status := strings.TrimSpace(string(output)) if status == "active" || status == "activating" { return name, nil } } } // Default to "pulse" if none are active return "pulse", nil } // downloadBinary downloads a specific version binary from GitHub func (a *InstallShAdapter) downloadBinary(ctx context.Context, version string) (string, error) { // Ensure version has 'v' prefix if !strings.HasPrefix(version, "v") { version = "v" + version } // Determine architecture arch := "amd64" if _, err := os.Stat("/proc/cpuinfo"); err == nil { output, _ := exec.Command("uname", "-m").Output() machine := strings.TrimSpace(string(output)) if machine == "aarch64" || machine == "arm64" { arch = "arm64" } } // Download URL - tarball with version in filename tarballName := fmt.Sprintf("pulse-%s-linux-%s.tar.gz", version, arch) url := fmt.Sprintf("https://github.com/rcourtman/Pulse/releases/download/%s/%s", version, tarballName) // Create temp file tmpDir, err := os.MkdirTemp("", "pulse-rollback-*") if err != nil { return "", err } tarballPath := filepath.Join(tmpDir, tarballName) // Download tarball cmd := exec.CommandContext(ctx, "curl", "-fsSL", "-o", tarballPath, url) if err := cmd.Run(); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("download failed: %w", err) } // Download checksum checksumURL := url + ".sha256" checksumPath := tarballPath + ".sha256" cmd = exec.CommandContext(ctx, "curl", "-fsSL", "-o", checksumPath, checksumURL) if err := cmd.Run(); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to download checksum: %w", err) } checksumData, err := os.ReadFile(checksumPath) if err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to read checksum file: %w", err) } expectedHash := strings.Fields(strings.TrimSpace(string(checksumData))) if len(expectedHash) == 0 { os.RemoveAll(tmpDir) return "", fmt.Errorf("checksum file was empty") } file, err := os.Open(tarballPath) if err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to open downloaded tarball: %w", err) } defer file.Close() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to hash downloaded binary: %w", err) } actualHash := hex.EncodeToString(hasher.Sum(nil)) if !strings.EqualFold(actualHash, expectedHash[0]) { os.RemoveAll(tmpDir) return "", fmt.Errorf("checksum verification failed for %s", tarballName) } _ = os.Remove(checksumPath) // Extract tarball to get the binary extractDir := filepath.Join(tmpDir, "extracted") if err := os.MkdirAll(extractDir, 0755); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to create extract directory: %w", err) } // Extract: tar -xzf tarball -C extractDir cmd = exec.CommandContext(ctx, "tar", "-xzf", tarballPath, "-C", extractDir) if err := cmd.Run(); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to extract tarball: %w", err) } // The binary is at extractDir/bin/pulse binaryPath := filepath.Join(extractDir, "bin", "pulse") if _, err := os.Stat(binaryPath); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("binary not found in tarball at expected path: %w", err) } // Remove tarball to save space _ = os.Remove(tarballPath) return binaryPath, nil } // stopService stops the Pulse service func (a *InstallShAdapter) stopService(ctx context.Context, serviceName string) error { cmd := exec.CommandContext(ctx, "systemctl", "stop", serviceName) return cmd.Run() } // startService starts the Pulse service func (a *InstallShAdapter) startService(ctx context.Context, serviceName string) error { cmd := exec.CommandContext(ctx, "systemctl", "start", serviceName) return cmd.Run() } // restoreConfig restores configuration from backup func (a *InstallShAdapter) restoreConfig(ctx context.Context, backupPath, targetPath string) error { // Remove current config if err := os.RemoveAll(targetPath); err != nil { return fmt.Errorf("failed to remove current config: %w", err) } // Copy backup to target cmd := exec.CommandContext(ctx, "cp", "-a", backupPath, targetPath) return cmd.Run() } // installBinary installs a binary to the target location func (a *InstallShAdapter) installBinary(ctx context.Context, sourcePath, targetPath string) error { // Backup current binary if _, err := os.Stat(targetPath); err == nil { backupPath := targetPath + ".pre-rollback" _ = os.Rename(targetPath, backupPath) } // Copy new binary if err := exec.CommandContext(ctx, "cp", sourcePath, targetPath).Run(); err != nil { return err } // Set permissions if err := os.Chmod(targetPath, 0755); err != nil { return err } // Set ownership return exec.CommandContext(ctx, "chown", "pulse:pulse", targetPath).Run() } // waitForHealth waits for the service to become healthy func (a *InstallShAdapter) waitForHealth(ctx context.Context, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { // Try to hit health endpoint cmd := exec.CommandContext(ctx, "curl", "-fsS", "http://localhost:7655/api/health") if err := cmd.Run(); err == nil { return nil } // Wait before retry select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } } return fmt.Errorf("service did not become healthy within %v", timeout) } // downloadInstallScript downloads the install.sh script func (a *InstallShAdapter) downloadInstallScript(ctx context.Context) (string, error) { tmpDir, err := os.MkdirTemp("", "pulse-installsh-*") if err != nil { return "", err } defer os.RemoveAll(tmpDir) scriptPath := filepath.Join(tmpDir, "install.sh") cmd := exec.CommandContext(ctx, "curl", "-fsSL", "-o", scriptPath, a.installScriptURL) if err := cmd.Run(); err != nil { return "", err } checksumURL := a.installScriptURL + ".sha256" checksumPath := scriptPath + ".sha256" cmd = exec.CommandContext(ctx, "curl", "-fsSL", "-o", checksumPath, checksumURL) if err := cmd.Run(); err != nil { return "", fmt.Errorf("failed to download install.sh checksum: %w", err) } checksumData, err := os.ReadFile(checksumPath) if err != nil { return "", fmt.Errorf("failed to read install.sh checksum: %w", err) } expectedParts := strings.Fields(strings.TrimSpace(string(checksumData))) if len(expectedParts) == 0 { return "", fmt.Errorf("install.sh checksum file was empty") } file, err := os.Open(scriptPath) if err != nil { return "", fmt.Errorf("failed to open install.sh for hashing: %w", err) } defer file.Close() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { return "", fmt.Errorf("failed to hash install.sh: %w", err) } actualHash := hex.EncodeToString(hasher.Sum(nil)) if !strings.EqualFold(actualHash, expectedParts[0]) { return "", fmt.Errorf("install.sh checksum verification failed") } content, err := os.ReadFile(scriptPath) if err != nil { return "", fmt.Errorf("failed to read install.sh: %w", err) } return string(content), nil } // parseProgress attempts to parse progress from install script output func (a *InstallShAdapter) parseProgress(line string) UpdateProgress { line = strings.ToLower(line) // Map common install.sh output to progress stages patterns := map[string]UpdateProgress{ "downloading": {Stage: "downloading", Progress: 30, Message: "Downloading update..."}, "extracting": {Stage: "extracting", Progress: 40, Message: "Extracting files..."}, "installing": {Stage: "installing", Progress: 60, Message: "Installing..."}, "backup": {Stage: "backing-up", Progress: 25, Message: "Creating backup..."}, "configur": {Stage: "configuring", Progress: 70, Message: "Configuring..."}, "restart": {Stage: "restarting", Progress: 90, Message: "Restarting service..."}, "complet": {Stage: "completed", Progress: 100, Message: "Update completed", IsComplete: true}, "success": {Stage: "completed", Progress: 100, Message: "Update completed", IsComplete: true}, } for pattern, progress := range patterns { if strings.Contains(line, pattern) { return progress } } return UpdateProgress{} } // readLastLines reads the last N lines from a file func (a *InstallShAdapter) readLastLines(filepath string, n int) string { file, err := os.Open(filepath) if err != nil { return "" } defer file.Close() // Read file backwards (simplified approach - read all lines and take last N) var lines []string scanner := bufio.NewScanner(file) for scanner.Scan() { lines = append(lines, scanner.Text()) } if len(lines) == 0 { return "" } start := len(lines) - n if start < 0 { start = 0 } return strings.Join(lines[start:], "\n") } // DockerUpdater provides instructions for Docker deployments type DockerUpdater struct{} func NewDockerUpdater() *DockerUpdater { return &DockerUpdater{} } func (u *DockerUpdater) SupportsApply() bool { return false } func (u *DockerUpdater) GetDeploymentType() string { return "docker" } func (u *DockerUpdater) PrepareUpdate(ctx context.Context, request UpdateRequest) (*UpdatePlan, error) { return &UpdatePlan{ CanAutoUpdate: false, Instructions: []string{ fmt.Sprintf("docker pull rcourtman/pulse:%s", strings.TrimPrefix(request.Version, "v")), "docker stop pulse", fmt.Sprintf("docker run -d --name pulse rcourtman/pulse:%s", strings.TrimPrefix(request.Version, "v")), }, RequiresRoot: false, RollbackSupport: true, EstimatedTime: "1-2 minutes", }, nil } func (u *DockerUpdater) Execute(ctx context.Context, request UpdateRequest, progressCb ProgressCallback) error { return fmt.Errorf("docker deployments do not support automated updates") } func (u *DockerUpdater) Rollback(ctx context.Context, eventID string) error { return fmt.Errorf("docker rollback not supported via API") } // AURUpdater provides instructions for Arch Linux AUR deployments type AURUpdater struct{} func NewAURUpdater() *AURUpdater { return &AURUpdater{} } func (u *AURUpdater) SupportsApply() bool { return false } func (u *AURUpdater) GetDeploymentType() string { return "aur" } func (u *AURUpdater) PrepareUpdate(ctx context.Context, request UpdateRequest) (*UpdatePlan, error) { return &UpdatePlan{ CanAutoUpdate: false, Instructions: []string{ "yay -Syu pulse-monitoring", "# or", "paru -Syu pulse-monitoring", }, RequiresRoot: false, RollbackSupport: false, EstimatedTime: "1-2 minutes", }, nil } func (u *AURUpdater) Execute(ctx context.Context, request UpdateRequest, progressCb ProgressCallback) error { return fmt.Errorf("aur deployments must be updated via package manager") } func (u *AURUpdater) Rollback(ctx context.Context, eventID string) error { return fmt.Errorf("aur rollback not supported") } // Ensure adapters implement Updater interface var ( _ Updater = (*InstallShAdapter)(nil) _ Updater = (*DockerUpdater)(nil) _ Updater = (*AURUpdater)(nil) )