mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-26 10:31:17 +00:00
740 lines
21 KiB
Go
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)
|
|
)
|