package updates import ( "bufio" "context" "fmt" "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) } log.Info(). Str("event_id", eventID). Str("backup", entry.BackupPath). Str("version_from", entry.VersionTo). Str("version_to", entry.VersionFrom). Msg("Starting rollback") // TODO: Implement actual rollback logic // This would involve: // 1. Stop service // 2. Restore files from backup // 3. Restart service // 4. Create rollback history entry return fmt.Errorf("rollback not yet implemented") } // downloadInstallScript downloads the install.sh script func (a *InstallShAdapter) downloadInstallScript(ctx context.Context) (string, error) { // Use curl to download (simpler than http.Get for script execution) cmd := exec.CommandContext(ctx, "curl", "-fsSL", a.installScriptURL) output, err := cmd.Output() if err != nil { return "", err } return string(output), 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) )