Pulse/internal/updates/adapter_installsh.go
2026-04-14 19:22:12 +01:00

740 lines
21 KiB
Go

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
releaseAssetBaseURL string
logDir string
}
// NewInstallShAdapter creates a new install.sh adapter
func NewInstallShAdapter(history *UpdateHistory) *InstallShAdapter {
return &InstallShAdapter{
history: history,
releaseAssetBaseURL: "https://github.com/rcourtman/Pulse/releases/download",
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...",
})
// 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)
}
installScriptURL := a.installScriptURLForVersion(request.Version)
log.Info().
Str("url", installScriptURL).
Str("version", request.Version).
Msg("Downloading install script")
installScript, err := a.downloadInstallScript(ctx, installScriptURL)
if err != nil {
return fmt.Errorf("failed to download install script: %w", err)
}
// Prepare command
progressCb(UpdateProgress{
Stage: "preparing",
Progress: 20,
Message: "Preparing update...",
})
// 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
}
func (a *InstallShAdapter) installScriptURLForVersion(version string) string {
tag := strings.TrimSpace(version)
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
return fmt.Sprintf("%s/%s/install.sh", a.releaseAssetBaseURL, tag)
}
// 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, installScriptURL string) (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, installScriptURL)
if err := cmd.Run(); err != nil {
return "", err
}
checksumURL := 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 {
if n <= 0 {
return ""
}
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{}
// NewDockerUpdater creates an updater for Docker deployments.
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{}
// NewAURUpdater creates an updater for Arch Linux AUR deployments.
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)
)